Skip to content

Commit

Permalink
feat: enable type checking for query selectors
Browse files Browse the repository at this point in the history
  • Loading branch information
maxnowack committed Jan 13, 2025
1 parent 56f3e15 commit b15b75e
Show file tree
Hide file tree
Showing 3 changed files with 66 additions and 25 deletions.
40 changes: 37 additions & 3 deletions packages/base/core/src/types/Selector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// borrowed from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/meteor/mongo.d.ts
// original from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/meteor/mongo.d.ts

export interface FieldExpression<T> {
$eq?: T | undefined,
Expand Down Expand Up @@ -35,11 +35,45 @@ export interface FieldExpression<T> {
$bitsAnySet?: any,
}

// Utility type to check if a type is an array or object.
type IsObject<T> = T extends object ? (T extends Array<any> ? false : true) : false

// Recursive type to generate dot-notation keys
type DotNotationKeys<T> = {
[K in keyof T & (string | number)]:
T[K] extends Array<infer U>
// If it's an array, include both the index and the $ wildcard
? `${K}` | `${K}.$` | `${K}.${DotNotationKeys<U>}`
// If it's an object, recurse into it
: IsObject<T[K]> extends true
? `${K}` | `${K}.${DotNotationKeys<T[K]>}`
: `${K}` // Base case: Just return the key
}[keyof T & (string | number)]

type Split<S extends string, Delimiter extends string> =
S extends `${infer Head}${Delimiter}${infer Tail}`
? [Head, ...Split<Tail, Delimiter>]
: [S]

type GetTypeByParts<T, Parts extends readonly string[]> =
Parts extends [infer Head, ...infer Tail]
? Head extends keyof T
? GetTypeByParts<T[Head], Extract<Tail, string[]>>
: Head extends '$'
? T extends Array<infer U>
? GetTypeByParts<U, Extract<Tail, string[]>>
: never
: never
: T

type Get<T, Path extends string> =
GetTypeByParts<T, Split<Path, '.'>>

type Flatten<T> = T extends any[] ? T[0] : T

type FlatQuery<T> = {
[P in keyof T]?: Flatten<T[P]> | RegExp | FieldExpression<Flatten<T[P]>>
} & Record<string, any>
[P in DotNotationKeys<T>]?: Flatten<Get<T, P>> | RegExp | FieldExpression<Flatten<Get<T, P>>>
}
type Query<T> = FlatQuery<T> & {
$or?: Query<T>[] | undefined,
$and?: Query<T>[] | undefined,
Expand Down
19 changes: 10 additions & 9 deletions packages/base/core/src/utils/getMatchingKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,25 @@ import serializeValue from './serializeValue'
export default function getMatchingKeys<
T extends BaseItem<I> = BaseItem, I = any,
>(field: string, selector: FlatSelector<T>): string[] | null {
if (selector[field] instanceof RegExp) return null
if (selector[field] != null) {
if (isFieldExpression(selector[field])) {
const is$in = isFieldExpression(selector[field])
&& Array.isArray(selector[field].$in)
&& selector[field].$in.length > 0
const fieldSelector = (selector as Record<string, any>)[field]
if (fieldSelector instanceof RegExp) return null
if (fieldSelector != null) {
if (isFieldExpression(fieldSelector)) {
const is$in = isFieldExpression(fieldSelector)
&& Array.isArray(fieldSelector.$in)
&& fieldSelector.$in.length > 0
if (is$in) {
const optimizedSelector = { ...selector, [field]: { ...selector[field] } }
const optimizedSelector = { ...selector, [field]: { ...fieldSelector } } as Record<string, any>
delete optimizedSelector[field].$in
if (Object.keys(optimizedSelector[field] as object).length === 0) {
delete optimizedSelector[field]
}

return (selector[field].$in as I[]).map(serializeValue)
return (fieldSelector.$in as I[]).map(serializeValue)
}
return null
}
return [serializeValue(selector[field])]
return [serializeValue(fieldSelector)]
}

return null
Expand Down
32 changes: 19 additions & 13 deletions packages/base/sync/src/SyncManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ReactivityAdapter,
Changeset,
LoadResponse,
Selector,
} from '@signaldb/core'
import { Collection, randomId, isEqual } from '@signaldb/core'
import debounce from './utils/debounce'
Expand Down Expand Up @@ -469,7 +470,9 @@ export default class SyncManager<
const syncTime = Date.now()

const lastFinishedSync = this.syncOperations.findOne({ collectionName: name, status: 'done' }, { sort: { end: -1 } })
const lastSnapshot = this.snapshots.findOne({ collectionName: name }, { sort: { time: -1 } })
const lastSnapshot = this.snapshots.findOne({
collectionName: name,
} as Selector<Snapshot<any>>, { sort: { time: -1 } })
const currentChanges = this.changes.find({
collectionName: name,
$and: [
Expand All @@ -487,15 +490,15 @@ export default class SyncManager<
}),
push: changes => this.options.push(collectionOptions, { changes }),
insert: (item) => {
if (item.id && !!collection.findOne({ id: item.id })) {
if (item.id && !!collection.findOne({ id: item.id } as Selector<any>)) {
this.remoteChanges.push({
collectionName: name,
type: 'update',
data: { id: item.id, modifier: { $set: item } },
})

// update the item if it already exists
collection.updateOne({ id: item.id }, { $set: item })
collection.updateOne({ id: item.id } as Selector<any>, { $set: item })
return
}
this.remoteChanges.push({
Expand All @@ -506,7 +509,7 @@ export default class SyncManager<
collection.insert(item)
},
update: (itemId, modifier) => {
if (itemId && !collection.findOne({ id: itemId })) {
if (itemId && !collection.findOne({ id: itemId } as Selector<any>)) {
const item = { ...modifier.$set as ItemType, id: itemId }
this.remoteChanges.push({
collectionName: name,
Expand All @@ -523,16 +526,16 @@ export default class SyncManager<
type: 'update',
data: { id: itemId, modifier },
})
collection.updateOne({ id: itemId } as Record<string, any>, modifier)
collection.updateOne({ id: itemId } as Selector<any>, modifier)
},
remove: (itemId) => {
if (!collection.findOne({ id: itemId } as Record<string, any>)) return
if (!collection.findOne({ id: itemId } as Selector<any>)) return
this.remoteChanges.push({
collectionName: name,
type: 'remove',
data: itemId,
})
collection.removeOne({ id: itemId } as Record<string, any>)
collection.removeOne({ id: itemId } as Selector<any>)
},
batch: (fn) => {
collection.batch(() => {
Expand All @@ -542,7 +545,10 @@ export default class SyncManager<
})
.then(async (snapshot) => {
// clean up old snapshots
this.snapshots.removeMany({ collectionName: name, time: { $lte: syncTime } })
this.snapshots.removeMany({
collectionName: name,
time: { $lte: syncTime },
} as Selector<any>)

// clean up processed changes
this.changes.removeMany({
Expand Down Expand Up @@ -581,21 +587,21 @@ export default class SyncManager<

// find all items that are not in the snapshot
const nonExistingItemIds = collection.find({
id: { $nin: snapshot.map(item => item.id) } as any,
}).map(item => item.id) as IdType[]
id: { $nin: snapshot.map(item => item.id) },
} as Selector<any>).map(item => item.id) as IdType[]

collection.batch(() => {
// update all items that are in the snapshot
snapshot.forEach((item) => {
const itemExists = !!collection.findOne({ id: item.id as any })
const itemExists = !!collection.findOne({ id: item.id } as Selector<any>)
/* istanbul ignore else -- @preserve */
if (itemExists) {
this.remoteChanges.push({
collectionName: name,
type: 'update',
data: { id: item.id, modifier: { $set: item } },
})
collection.updateOne({ id: item.id as any }, { $set: item })
collection.updateOne({ id: item.id } as Selector<any>, { $set: item })
} else { // this case should never happen
this.remoteChanges.push({
collectionName: name,
Expand All @@ -608,7 +614,7 @@ export default class SyncManager<

// remove all items that are not in the snapshot
nonExistingItemIds.forEach((id) => {
collection.removeOne({ id: id as any })
collection.removeOne({ id: id } as Selector<any>)
})
})
})
Expand Down

0 comments on commit b15b75e

Please sign in to comment.