@tanstack/trailbase-db-collection 0.1.77 → 0.1.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,8 @@ function trailBaseCollectionOptions(config) {
26
26
  const serialUpd = (item) => convertPartial(config.serialize, item);
27
27
  const serialIns = (item) => convert(config.serialize, item);
28
28
  const seenIds = new store.Store(/* @__PURE__ */ new Map());
29
+ const internalSyncMode = config.syncMode ?? `eager`;
30
+ let fullSyncCompleted = false;
29
31
  const awaitIds = (ids, timeout = 120 * 1e3) => {
30
32
  const completed = (value) => ids.every((id) => value.has(id));
31
33
  if (completed(seenIds.state)) {
@@ -33,13 +35,13 @@ function trailBaseCollectionOptions(config) {
33
35
  }
34
36
  return new Promise((resolve, reject) => {
35
37
  const timeoutId = setTimeout(() => {
36
- unsubscribe();
38
+ sub.unsubscribe();
37
39
  reject(new errors.TimeoutWaitingForIdsError(ids.toString()));
38
40
  }, timeout);
39
- const unsubscribe = seenIds.subscribe((value) => {
40
- if (completed(value.currentVal)) {
41
+ const sub = seenIds.subscribe((value) => {
42
+ if (completed(value)) {
41
43
  clearTimeout(timeoutId);
42
- unsubscribe();
44
+ sub.unsubscribe();
43
45
  resolve();
44
46
  }
45
47
  });
@@ -56,37 +58,59 @@ function trailBaseCollectionOptions(config) {
56
58
  const sync = {
57
59
  sync: (params) => {
58
60
  const { begin, write, commit, markReady } = params;
59
- async function initialFetch() {
60
- const limit = 256;
61
- let response = await config.recordApi.list({
62
- pagination: {
63
- limit
64
- }
65
- });
66
- let cursor = response.cursor;
67
- let got = 0;
68
- begin();
61
+ const cursors = /* @__PURE__ */ new Map();
62
+ async function load(opts) {
63
+ const lastKey = opts.cursor?.lastKey;
64
+ let cursor = lastKey !== void 0 ? cursors.get(lastKey) : void 0;
65
+ let offset = (opts.offset ?? 0) > 0 ? opts.offset : void 0;
66
+ const order = buildOrder(opts);
67
+ const filters = buildFilters(
68
+ opts,
69
+ config
70
+ );
71
+ let remaining = opts.limit ?? Number.MAX_VALUE;
72
+ if (remaining <= 0) {
73
+ return;
74
+ }
69
75
  while (true) {
76
+ const limit = Math.min(remaining, 256);
77
+ const response = await config.recordApi.list({
78
+ pagination: {
79
+ limit,
80
+ offset,
81
+ cursor
82
+ },
83
+ order,
84
+ filters
85
+ });
70
86
  const length = response.records.length;
71
- if (length === 0) break;
72
- got = got + length;
73
- for (const item of response.records) {
87
+ if (length === 0) {
88
+ break;
89
+ }
90
+ begin();
91
+ for (let i = 0; i < Math.min(length, remaining); ++i) {
74
92
  write({
75
93
  type: `insert`,
76
- value: parse(item)
94
+ value: parse(response.records[i])
77
95
  });
78
96
  }
79
- if (length < limit) break;
80
- response = await config.recordApi.list({
81
- pagination: {
82
- limit,
83
- cursor,
84
- offset: cursor === void 0 ? got : void 0
97
+ commit();
98
+ remaining -= length;
99
+ if (length < limit || remaining <= 0) {
100
+ if (response.cursor) {
101
+ cursors.set(
102
+ getKey(parse(response.records.at(-1))),
103
+ response.cursor
104
+ );
85
105
  }
86
- });
87
- cursor = response.cursor;
106
+ break;
107
+ }
108
+ if (offset !== void 0) {
109
+ offset += length;
110
+ } else {
111
+ cursor = response.cursor;
112
+ }
88
113
  }
89
- commit();
90
114
  }
91
115
  async function listen(reader) {
92
116
  while (true) {
@@ -125,7 +149,10 @@ function trailBaseCollectionOptions(config) {
125
149
  const reader = eventReader = eventStream.getReader();
126
150
  listen(reader);
127
151
  try {
128
- await initialFetch();
152
+ if (internalSyncMode === `eager`) {
153
+ await load({});
154
+ fullSyncCompleted = true;
155
+ }
129
156
  } catch (e) {
130
157
  cancelEventReader();
131
158
  throw e;
@@ -150,9 +177,21 @@ function trailBaseCollectionOptions(config) {
150
177
  reader.closed.finally(() => clearInterval(periodicCleanupTask));
151
178
  }
152
179
  start();
180
+ if (internalSyncMode === `eager`) {
181
+ return;
182
+ }
183
+ return {
184
+ loadSubset: load,
185
+ getSyncMetadata: () => ({
186
+ syncMode: internalSyncMode
187
+ })
188
+ };
153
189
  },
154
190
  // Expose the getSyncMetadata function
155
- getSyncMetadata: void 0
191
+ getSyncMetadata: () => ({
192
+ syncMode: internalSyncMode,
193
+ fullSyncComplete: fullSyncCompleted
194
+ })
156
195
  };
157
196
  return {
158
197
  ...config,
@@ -202,5 +241,85 @@ function trailBaseCollectionOptions(config) {
202
241
  }
203
242
  };
204
243
  }
244
+ function buildOrder(opts) {
245
+ return opts.orderBy?.map((o) => {
246
+ switch (o.expression.type) {
247
+ case "ref": {
248
+ const field = o.expression.path[0];
249
+ if (o.compareOptions.direction == "asc") {
250
+ return `+${field}`;
251
+ }
252
+ return `-${field}`;
253
+ }
254
+ default: {
255
+ console.warn(
256
+ "Skipping unsupported order clause:",
257
+ JSON.stringify(o.expression)
258
+ );
259
+ return void 0;
260
+ }
261
+ }
262
+ }).filter((f) => f !== void 0);
263
+ }
264
+ function buildCompareOp(name) {
265
+ switch (name) {
266
+ case "eq":
267
+ return "equal";
268
+ case "ne":
269
+ return "notEqual";
270
+ case "gt":
271
+ return "greaterThan";
272
+ case "gte":
273
+ return "greaterThanEqual";
274
+ case "lt":
275
+ return "lessThan";
276
+ case "lte":
277
+ return "lessThanEqual";
278
+ default:
279
+ return void 0;
280
+ }
281
+ }
282
+ function buildFilters(opts, config) {
283
+ const where = opts.where;
284
+ if (where === void 0) {
285
+ return void 0;
286
+ }
287
+ function serializeValue(column, value) {
288
+ const conv = config.serialize[column];
289
+ if (conv) {
290
+ return `${conv(value)}`;
291
+ }
292
+ if (typeof value === "boolean") {
293
+ return value ? "1" : "0";
294
+ }
295
+ return `${value}`;
296
+ }
297
+ switch (where.type) {
298
+ case "func": {
299
+ const field = where.args[0];
300
+ const val = where.args[1];
301
+ const op = buildCompareOp(where.name);
302
+ if (op === void 0) {
303
+ break;
304
+ }
305
+ if (field?.type === "ref" && val?.type === "val") {
306
+ const column = field.path.at(0);
307
+ if (column) {
308
+ const f = [
309
+ {
310
+ column: field.path.at(0) ?? "",
311
+ op,
312
+ value: serializeValue(column, val.value)
313
+ }
314
+ ];
315
+ return f;
316
+ }
317
+ }
318
+ break;
319
+ }
320
+ }
321
+ console.warn("where clause which is not (yet) supported", opts.where);
322
+ return void 0;
323
+ }
205
324
  exports.trailBaseCollectionOptions = trailBaseCollectionOptions;
206
325
  //# sourceMappingURL=trailbase.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"trailbase.cjs","sources":["../../src/trailbase.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-unnecessary-condition */\nimport { Store } from '@tanstack/store'\nimport {\n ExpectedDeleteTypeError,\n ExpectedInsertTypeError,\n ExpectedUpdateTypeError,\n TimeoutWaitingForIdsError,\n} from './errors'\nimport type { Event, RecordApi } from 'trailbase'\n\nimport type {\n BaseCollectionConfig,\n CollectionConfig,\n DeleteMutationFnParams,\n InsertMutationFnParams,\n SyncConfig,\n UpdateMutationFnParams,\n UtilsRecord,\n} from '@tanstack/db'\n\ntype ShapeOf<T> = Record<keyof T, unknown>\ntype Conversion<I, O> = (value: I) => O\n\ntype OptionalConversions<\n InputType extends ShapeOf<OutputType>,\n OutputType extends ShapeOf<InputType>,\n> = {\n // Excludes all keys that require a conversation.\n [K in keyof InputType as InputType[K] extends OutputType[K]\n ? K\n : never]?: Conversion<InputType[K], OutputType[K]>\n}\n\ntype RequiredConversions<\n InputType extends ShapeOf<OutputType>,\n OutputType extends ShapeOf<InputType>,\n> = {\n // Excludes all keys that do not strictly require a conversation.\n [K in keyof InputType as InputType[K] extends OutputType[K]\n ? never\n : K]: Conversion<InputType[K], OutputType[K]>\n}\n\ntype Conversions<\n InputType extends ShapeOf<OutputType>,\n OutputType extends ShapeOf<InputType>,\n> = OptionalConversions<InputType, OutputType> &\n RequiredConversions<InputType, OutputType>\n\nfunction convert<\n InputType extends ShapeOf<OutputType> & Record<string, unknown>,\n OutputType extends ShapeOf<InputType>,\n>(\n conversions: Conversions<InputType, OutputType>,\n input: InputType,\n): OutputType {\n const c = conversions as Record<string, Conversion<InputType, OutputType>>\n\n return Object.fromEntries(\n Object.keys(input).map((k: string) => {\n const value = input[k]\n return [k, c[k]?.(value as any) ?? value]\n }),\n ) as OutputType\n}\n\nfunction convertPartial<\n InputType extends ShapeOf<OutputType> & Record<string, unknown>,\n OutputType extends ShapeOf<InputType>,\n>(\n conversions: Conversions<InputType, OutputType>,\n input: Partial<InputType>,\n): Partial<OutputType> {\n const c = conversions as Record<string, Conversion<InputType, OutputType>>\n\n return Object.fromEntries(\n Object.keys(input).map((k: string) => {\n const value = input[k]\n return [k, c[k]?.(value as any) ?? value]\n }),\n ) as OutputType\n}\n\n/**\n * Configuration interface for Trailbase Collection\n */\nexport interface TrailBaseCollectionConfig<\n TItem extends ShapeOf<TRecord>,\n TRecord extends ShapeOf<TItem> = TItem,\n TKey extends string | number = string | number,\n> extends Omit<\n BaseCollectionConfig<TItem, TKey>,\n `onInsert` | `onUpdate` | `onDelete`\n> {\n /**\n * Record API name\n */\n recordApi: RecordApi<TRecord>\n\n parse: Conversions<TRecord, TItem>\n serialize: Conversions<TItem, TRecord>\n}\n\nexport type AwaitTxIdFn = (txId: string, timeout?: number) => Promise<boolean>\n\nexport interface TrailBaseCollectionUtils extends UtilsRecord {\n cancel: () => void\n}\n\nexport function trailBaseCollectionOptions<\n TItem extends ShapeOf<TRecord>,\n TRecord extends ShapeOf<TItem> = TItem,\n TKey extends string | number = string | number,\n>(\n config: TrailBaseCollectionConfig<TItem, TRecord, TKey>,\n): CollectionConfig<TItem, TKey, never, TrailBaseCollectionUtils> & {\n utils: TrailBaseCollectionUtils\n} {\n const getKey = config.getKey\n\n const parse = (record: TRecord) =>\n convert<TRecord, TItem>(config.parse, record)\n const serialUpd = (item: Partial<TItem>) =>\n convertPartial<TItem, TRecord>(config.serialize, item)\n const serialIns = (item: TItem) =>\n convert<TItem, TRecord>(config.serialize, item)\n\n const seenIds = new Store(new Map<string, number>())\n\n const awaitIds = (\n ids: Array<string>,\n timeout: number = 120 * 1000,\n ): Promise<void> => {\n const completed = (value: Map<string, number>) =>\n ids.every((id) => value.has(id))\n if (completed(seenIds.state)) {\n return Promise.resolve()\n }\n\n return new Promise<void>((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n unsubscribe()\n reject(new TimeoutWaitingForIdsError(ids.toString()))\n }, timeout)\n\n const unsubscribe = seenIds.subscribe((value) => {\n if (completed(value.currentVal)) {\n clearTimeout(timeoutId)\n unsubscribe()\n resolve()\n }\n })\n })\n }\n\n let eventReader: ReadableStreamDefaultReader<Event> | undefined\n const cancelEventReader = () => {\n if (eventReader) {\n eventReader.cancel()\n eventReader.releaseLock()\n eventReader = undefined\n }\n }\n\n type SyncParams = Parameters<SyncConfig<TItem, TKey>[`sync`]>[0]\n const sync = {\n sync: (params: SyncParams) => {\n const { begin, write, commit, markReady } = params\n\n // Initial fetch.\n async function initialFetch() {\n const limit = 256\n let response = await config.recordApi.list({\n pagination: {\n limit,\n },\n })\n let cursor = response.cursor\n let got = 0\n\n begin()\n\n while (true) {\n const length = response.records.length\n if (length === 0) break\n\n got = got + length\n for (const item of response.records) {\n write({\n type: `insert`,\n value: parse(item),\n })\n }\n\n if (length < limit) break\n\n response = await config.recordApi.list({\n pagination: {\n limit,\n cursor,\n offset: cursor === undefined ? got : undefined,\n },\n })\n cursor = response.cursor\n }\n\n commit()\n }\n\n // Afterwards subscribe.\n async function listen(reader: ReadableStreamDefaultReader<Event>) {\n while (true) {\n const { done, value: event } = await reader.read()\n\n if (done || !event) {\n reader.releaseLock()\n eventReader = undefined\n return\n }\n\n begin()\n let value: TItem | undefined\n if (`Insert` in event) {\n value = parse(event.Insert as TRecord)\n write({ type: `insert`, value })\n } else if (`Delete` in event) {\n value = parse(event.Delete as TRecord)\n write({ type: `delete`, value })\n } else if (`Update` in event) {\n value = parse(event.Update as TRecord)\n write({ type: `update`, value })\n } else {\n console.error(`Error: ${event.Error}`)\n }\n commit()\n\n if (value) {\n seenIds.setState((curr: Map<string, number>) => {\n const newIds = new Map(curr)\n newIds.set(String(getKey(value)), Date.now())\n return newIds\n })\n }\n }\n }\n\n async function start() {\n const eventStream = await config.recordApi.subscribe(`*`)\n const reader = (eventReader = eventStream.getReader())\n\n // Start listening for subscriptions first. Otherwise, we'd risk a gap\n // between the initial fetch and starting to listen.\n listen(reader)\n\n try {\n await initialFetch()\n } catch (e) {\n cancelEventReader()\n throw e\n } finally {\n // Mark ready both if everything went well or if there's an error to\n // avoid blocking apps waiting for `.preload()` to finish.\n markReady()\n }\n\n // Lastly, start a periodic cleanup task that will be removed when the\n // reader closes.\n const periodicCleanupTask = setInterval(() => {\n seenIds.setState((curr) => {\n const now = Date.now()\n let anyExpired = false\n\n const notExpired = Array.from(curr.entries()).filter(([_, v]) => {\n const expired = now - v > 300 * 1000\n anyExpired = anyExpired || expired\n return !expired\n })\n\n if (anyExpired) {\n return new Map(notExpired)\n }\n return curr\n })\n }, 120 * 1000)\n\n reader.closed.finally(() => clearInterval(periodicCleanupTask))\n }\n\n start()\n },\n // Expose the getSyncMetadata function\n getSyncMetadata: undefined,\n }\n\n return {\n ...config,\n sync,\n getKey,\n onInsert: async (\n params: InsertMutationFnParams<TItem, TKey>,\n ): Promise<Array<number | string>> => {\n const ids = await config.recordApi.createBulk(\n params.transaction.mutations.map((tx) => {\n const { type, modified } = tx\n if (type !== `insert`) {\n throw new ExpectedInsertTypeError(type)\n }\n return serialIns(modified)\n }),\n )\n\n // The optimistic mutation overlay is removed on return, so at this point\n // we have to ensure that the new record was properly added to the local\n // DB by the subscription.\n await awaitIds(ids.map((id) => String(id)))\n\n return ids\n },\n onUpdate: async (params: UpdateMutationFnParams<TItem, TKey>) => {\n const ids: Array<string> = await Promise.all(\n params.transaction.mutations.map(async (tx) => {\n const { type, changes, key } = tx\n if (type !== `update`) {\n throw new ExpectedUpdateTypeError(type)\n }\n\n await config.recordApi.update(key, serialUpd(changes))\n\n return String(key)\n }),\n )\n\n // The optimistic mutation overlay is removed on return, so at this point\n // we have to ensure that the new record was properly updated in the local\n // DB by the subscription.\n await awaitIds(ids)\n },\n onDelete: async (params: DeleteMutationFnParams<TItem, TKey>) => {\n const ids: Array<string> = await Promise.all(\n params.transaction.mutations.map(async (tx) => {\n const { type, key } = tx\n if (type !== `delete`) {\n throw new ExpectedDeleteTypeError(type)\n }\n\n await config.recordApi.delete(key)\n return String(key)\n }),\n )\n\n // The optimistic mutation overlay is removed on return, so at this point\n // we have to ensure that the new record was properly updated in the local\n // DB by the subscription.\n await awaitIds(ids)\n },\n utils: {\n cancel: cancelEventReader,\n },\n }\n}\n"],"names":["Store","TimeoutWaitingForIdsError","ExpectedInsertTypeError","ExpectedUpdateTypeError","ExpectedDeleteTypeError"],"mappings":"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"}
1
+ {"version":3,"file":"trailbase.cjs","sources":["../../src/trailbase.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-unnecessary-condition */\nimport { Store } from '@tanstack/store'\nimport {\n ExpectedDeleteTypeError,\n ExpectedInsertTypeError,\n ExpectedUpdateTypeError,\n TimeoutWaitingForIdsError,\n} from './errors'\nimport type { OrderByClause } from '../../db/dist/esm/query/ir'\nimport type { CompareOp, Event, FilterOrComposite, RecordApi } from 'trailbase'\n\nimport type {\n BaseCollectionConfig,\n CollectionConfig,\n DeleteMutationFnParams,\n InsertMutationFnParams,\n LoadSubsetOptions,\n SyncConfig,\n SyncMode,\n UpdateMutationFnParams,\n UtilsRecord,\n} from '@tanstack/db'\n\ntype ShapeOf<T> = Record<keyof T, unknown>\ntype Conversion<I, O> = (value: I) => O\n\ntype OptionalConversions<\n InputType extends ShapeOf<OutputType>,\n OutputType extends ShapeOf<InputType>,\n> = {\n // Excludes all keys that require a conversation.\n [K in keyof InputType as InputType[K] extends OutputType[K]\n ? K\n : never]?: Conversion<InputType[K], OutputType[K]>\n}\n\ntype RequiredConversions<\n InputType extends ShapeOf<OutputType>,\n OutputType extends ShapeOf<InputType>,\n> = {\n // Excludes all keys that do not strictly require a conversation.\n [K in keyof InputType as InputType[K] extends OutputType[K]\n ? never\n : K]: Conversion<InputType[K], OutputType[K]>\n}\n\ntype Conversions<\n InputType extends ShapeOf<OutputType>,\n OutputType extends ShapeOf<InputType>,\n> = OptionalConversions<InputType, OutputType> &\n RequiredConversions<InputType, OutputType>\n\nfunction convert<\n InputType extends ShapeOf<OutputType> & Record<string, unknown>,\n OutputType extends ShapeOf<InputType>,\n>(\n conversions: Conversions<InputType, OutputType>,\n input: InputType,\n): OutputType {\n const c = conversions as Record<string, Conversion<InputType, OutputType>>\n\n return Object.fromEntries(\n Object.keys(input).map((k: string) => {\n const value = input[k]\n return [k, c[k]?.(value as any) ?? value]\n }),\n ) as OutputType\n}\n\nfunction convertPartial<\n InputType extends ShapeOf<OutputType> & Record<string, unknown>,\n OutputType extends ShapeOf<InputType>,\n>(\n conversions: Conversions<InputType, OutputType>,\n input: Partial<InputType>,\n): Partial<OutputType> {\n const c = conversions as Record<string, Conversion<InputType, OutputType>>\n\n return Object.fromEntries(\n Object.keys(input).map((k: string) => {\n const value = input[k]\n return [k, c[k]?.(value as any) ?? value]\n }),\n ) as OutputType\n}\n\nexport type TrailBaseSyncMode = SyncMode\n\n/**\n * Configuration interface for Trailbase Collection\n */\nexport interface TrailBaseCollectionConfig<\n TItem extends ShapeOf<TRecord>,\n TRecord extends ShapeOf<TItem> = TItem,\n TKey extends string | number = string | number,\n> extends Omit<\n BaseCollectionConfig<TItem, TKey>,\n `onInsert` | `onUpdate` | `onDelete` | `syncMode`\n> {\n /**\n * Record API name\n */\n recordApi: RecordApi<TRecord>\n\n /**\n * The mode of sync to use for the collection.\n * @default `eager`\n */\n syncMode?: TrailBaseSyncMode\n\n parse: Conversions<TRecord, TItem>\n serialize: Conversions<TItem, TRecord>\n}\n\nexport type AwaitTxIdFn = (txId: string, timeout?: number) => Promise<boolean>\n\nexport interface TrailBaseCollectionUtils extends UtilsRecord {\n cancel: () => void\n}\n\nexport function trailBaseCollectionOptions<\n TItem extends ShapeOf<TRecord>,\n TRecord extends ShapeOf<TItem> = TItem,\n TKey extends string | number = string | number,\n>(\n config: TrailBaseCollectionConfig<TItem, TRecord, TKey>,\n): CollectionConfig<TItem, TKey, never, TrailBaseCollectionUtils> & {\n utils: TrailBaseCollectionUtils\n} {\n const getKey = config.getKey\n\n const parse = (record: TRecord) =>\n convert<TRecord, TItem>(config.parse, record)\n const serialUpd = (item: Partial<TItem>) =>\n convertPartial<TItem, TRecord>(config.serialize, item)\n const serialIns = (item: TItem) =>\n convert<TItem, TRecord>(config.serialize, item)\n\n const seenIds = new Store(new Map<string, number>())\n\n const internalSyncMode = config.syncMode ?? `eager`\n let fullSyncCompleted = false\n\n const awaitIds = (\n ids: Array<string>,\n timeout: number = 120 * 1000,\n ): Promise<void> => {\n const completed = (value: Map<string, number>) =>\n ids.every((id) => value.has(id))\n if (completed(seenIds.state)) {\n return Promise.resolve()\n }\n\n return new Promise<void>((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n sub.unsubscribe()\n reject(new TimeoutWaitingForIdsError(ids.toString()))\n }, timeout)\n\n const sub = seenIds.subscribe((value) => {\n if (completed(value)) {\n clearTimeout(timeoutId)\n sub.unsubscribe()\n resolve()\n }\n })\n })\n }\n\n let eventReader: ReadableStreamDefaultReader<Event> | undefined\n const cancelEventReader = () => {\n if (eventReader) {\n eventReader.cancel()\n eventReader.releaseLock()\n eventReader = undefined\n }\n }\n\n type SyncParams = Parameters<SyncConfig<TItem, TKey>[`sync`]>[0]\n const sync = {\n sync: (params: SyncParams) => {\n const { begin, write, commit, markReady } = params\n\n // NOTE: We cache cursors from prior fetches. TanStack/db expects that\n // cursors can be derived from a key, which is not true for TB, since\n // cursors are encrypted. This is leaky and therefore not ideal.\n const cursors = new Map<string | number, string>()\n\n // Load (more) data.\n async function load(opts: LoadSubsetOptions) {\n const lastKey = opts.cursor?.lastKey\n let cursor: string | undefined =\n lastKey !== undefined ? cursors.get(lastKey) : undefined\n let offset: number | undefined =\n (opts.offset ?? 0) > 0 ? opts.offset : undefined\n\n const order: Array<string> | undefined = buildOrder(opts)\n const filters: Array<FilterOrComposite> | undefined = buildFilters(\n opts,\n config,\n )\n\n let remaining: number = opts.limit ?? Number.MAX_VALUE\n if (remaining <= 0) {\n return\n }\n\n while (true) {\n const limit = Math.min(remaining, 256)\n const response = await config.recordApi.list({\n pagination: {\n limit,\n offset,\n cursor,\n },\n order,\n filters,\n })\n\n const length = response.records.length\n if (length === 0) {\n // Drained - read everything.\n break\n }\n\n begin()\n\n for (let i = 0; i < Math.min(length, remaining); ++i) {\n write({\n type: `insert`,\n value: parse(response.records[i]!),\n })\n }\n\n commit()\n\n remaining -= length\n\n // Drained or read enough.\n if (length < limit || remaining <= 0) {\n if (response.cursor) {\n cursors.set(\n getKey(parse(response.records.at(-1)!)),\n response.cursor,\n )\n }\n break\n }\n\n // Update params for next iteration.\n if (offset !== undefined) {\n offset += length\n } else {\n cursor = response.cursor\n }\n }\n }\n\n // Afterwards subscribe.\n async function listen(reader: ReadableStreamDefaultReader<Event>) {\n while (true) {\n const { done, value: event } = await reader.read()\n\n if (done || !event) {\n reader.releaseLock()\n eventReader = undefined\n return\n }\n\n begin()\n let value: TItem | undefined\n if (`Insert` in event) {\n value = parse(event.Insert as TRecord)\n write({ type: `insert`, value })\n } else if (`Delete` in event) {\n value = parse(event.Delete as TRecord)\n write({ type: `delete`, value })\n } else if (`Update` in event) {\n value = parse(event.Update as TRecord)\n write({ type: `update`, value })\n } else {\n console.error(`Error: ${event.Error}`)\n }\n commit()\n\n if (value) {\n seenIds.setState((curr: Map<string, number>) => {\n const newIds = new Map(curr)\n newIds.set(String(getKey(value)), Date.now())\n return newIds\n })\n }\n }\n }\n\n async function start() {\n const eventStream = await config.recordApi.subscribe(`*`)\n const reader = (eventReader = eventStream.getReader())\n\n // Start listening for subscriptions first. Otherwise, we'd risk a gap\n // between the initial fetch and starting to listen.\n listen(reader)\n\n try {\n // Eager mode: perform initial fetch to populate everything\n if (internalSyncMode === `eager`) {\n // Load everything on initial load.\n await load({})\n fullSyncCompleted = true\n }\n } catch (e) {\n cancelEventReader()\n throw e\n } finally {\n // Mark ready both if everything went well or if there's an error to\n // avoid blocking apps waiting for `.preload()` to finish.\n markReady()\n }\n\n // Lastly, start a periodic cleanup task that will be removed when the\n // reader closes.\n const periodicCleanupTask = setInterval(() => {\n seenIds.setState((curr) => {\n const now = Date.now()\n let anyExpired = false\n\n const notExpired = Array.from(curr.entries()).filter(([_, v]) => {\n const expired = now - v > 300 * 1000\n anyExpired = anyExpired || expired\n return !expired\n })\n\n if (anyExpired) {\n return new Map(notExpired)\n }\n return curr\n })\n }, 120 * 1000)\n\n reader.closed.finally(() => clearInterval(periodicCleanupTask))\n }\n\n start()\n\n // Eager mode doesn't need subset loading\n if (internalSyncMode === `eager`) {\n return\n }\n\n return {\n loadSubset: load,\n getSyncMetadata: () =>\n ({\n syncMode: internalSyncMode,\n }) as const,\n }\n },\n // Expose the getSyncMetadata function\n getSyncMetadata: () =>\n ({\n syncMode: internalSyncMode,\n fullSyncComplete: fullSyncCompleted,\n }) as const,\n }\n\n return {\n ...config,\n sync,\n getKey,\n onInsert: async (\n params: InsertMutationFnParams<TItem, TKey>,\n ): Promise<Array<number | string>> => {\n const ids = await config.recordApi.createBulk(\n params.transaction.mutations.map((tx) => {\n const { type, modified } = tx\n if (type !== `insert`) {\n throw new ExpectedInsertTypeError(type)\n }\n return serialIns(modified)\n }),\n )\n\n // The optimistic mutation overlay is removed on return, so at this point\n // we have to ensure that the new record was properly added to the local\n // DB by the subscription.\n await awaitIds(ids.map((id) => String(id)))\n\n return ids\n },\n onUpdate: async (params: UpdateMutationFnParams<TItem, TKey>) => {\n const ids: Array<string> = await Promise.all(\n params.transaction.mutations.map(async (tx) => {\n const { type, changes, key } = tx\n if (type !== `update`) {\n throw new ExpectedUpdateTypeError(type)\n }\n\n await config.recordApi.update(key, serialUpd(changes))\n\n return String(key)\n }),\n )\n\n // The optimistic mutation overlay is removed on return, so at this point\n // we have to ensure that the new record was properly updated in the local\n // DB by the subscription.\n await awaitIds(ids)\n },\n onDelete: async (params: DeleteMutationFnParams<TItem, TKey>) => {\n const ids: Array<string> = await Promise.all(\n params.transaction.mutations.map(async (tx) => {\n const { type, key } = tx\n if (type !== `delete`) {\n throw new ExpectedDeleteTypeError(type)\n }\n\n await config.recordApi.delete(key)\n return String(key)\n }),\n )\n\n // The optimistic mutation overlay is removed on return, so at this point\n // we have to ensure that the new record was properly updated in the local\n // DB by the subscription.\n await awaitIds(ids)\n },\n utils: {\n cancel: cancelEventReader,\n },\n }\n}\n\nfunction buildOrder(opts: LoadSubsetOptions): undefined | Array<string> {\n return opts.orderBy\n ?.map((o: OrderByClause) => {\n switch (o.expression.type) {\n case 'ref': {\n const field = o.expression.path[0]\n if (o.compareOptions.direction == 'asc') {\n return `+${field}`\n }\n return `-${field}`\n }\n default: {\n console.warn(\n 'Skipping unsupported order clause:',\n JSON.stringify(o.expression),\n )\n return undefined\n }\n }\n })\n .filter((f: string | undefined) => f !== undefined)\n}\n\nfunction buildCompareOp(name: string): CompareOp | undefined {\n switch (name) {\n case 'eq':\n return 'equal'\n case 'ne':\n return 'notEqual'\n case 'gt':\n return 'greaterThan'\n case 'gte':\n return 'greaterThanEqual'\n case 'lt':\n return 'lessThan'\n case 'lte':\n return 'lessThanEqual'\n default:\n return undefined\n }\n}\n\nfunction buildFilters<\n TItem extends ShapeOf<TRecord>,\n TRecord extends ShapeOf<TItem> = TItem,\n TKey extends string | number = string | number,\n>(\n opts: LoadSubsetOptions,\n config: TrailBaseCollectionConfig<TItem, TRecord, TKey>,\n): undefined | Array<FilterOrComposite> {\n const where = opts.where\n if (where === undefined) {\n return undefined\n }\n\n function serializeValue<T = any>(column: string, value: T): string {\n const conv = (config.serialize as any)[column]\n if (conv) {\n return `${conv(value)}`\n }\n\n if (typeof value === 'boolean') {\n return value ? '1' : '0'\n }\n\n return `${value}`\n }\n\n switch (where.type) {\n case 'func': {\n const field = where.args[0]\n const val = where.args[1]\n\n const op = buildCompareOp(where.name)\n if (op === undefined) {\n break\n }\n\n if (field?.type === 'ref' && val?.type === 'val') {\n const column = field.path.at(0)\n if (column) {\n const f = [\n {\n column: field.path.at(0) ?? '',\n op,\n value: serializeValue(column, val.value),\n },\n ]\n\n return f\n }\n }\n break\n }\n case 'ref':\n case 'val':\n break\n }\n\n console.warn('where clause which is not (yet) supported', opts.where)\n\n return undefined\n}\n"],"names":["Store","TimeoutWaitingForIdsError","ExpectedInsertTypeError","ExpectedUpdateTypeError","ExpectedDeleteTypeError"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { RecordApi } from 'trailbase';
2
- import { BaseCollectionConfig, CollectionConfig, UtilsRecord } from '@tanstack/db';
2
+ import { BaseCollectionConfig, CollectionConfig, SyncMode, UtilsRecord } from '@tanstack/db';
3
3
  type ShapeOf<T> = Record<keyof T, unknown>;
4
4
  type Conversion<I, O> = (value: I) => O;
5
5
  type OptionalConversions<InputType extends ShapeOf<OutputType>, OutputType extends ShapeOf<InputType>> = {
@@ -9,14 +9,20 @@ type RequiredConversions<InputType extends ShapeOf<OutputType>, OutputType exten
9
9
  [K in keyof InputType as InputType[K] extends OutputType[K] ? never : K]: Conversion<InputType[K], OutputType[K]>;
10
10
  };
11
11
  type Conversions<InputType extends ShapeOf<OutputType>, OutputType extends ShapeOf<InputType>> = OptionalConversions<InputType, OutputType> & RequiredConversions<InputType, OutputType>;
12
+ export type TrailBaseSyncMode = SyncMode;
12
13
  /**
13
14
  * Configuration interface for Trailbase Collection
14
15
  */
15
- export interface TrailBaseCollectionConfig<TItem extends ShapeOf<TRecord>, TRecord extends ShapeOf<TItem> = TItem, TKey extends string | number = string | number> extends Omit<BaseCollectionConfig<TItem, TKey>, `onInsert` | `onUpdate` | `onDelete`> {
16
+ export interface TrailBaseCollectionConfig<TItem extends ShapeOf<TRecord>, TRecord extends ShapeOf<TItem> = TItem, TKey extends string | number = string | number> extends Omit<BaseCollectionConfig<TItem, TKey>, `onInsert` | `onUpdate` | `onDelete` | `syncMode`> {
16
17
  /**
17
18
  * Record API name
18
19
  */
19
20
  recordApi: RecordApi<TRecord>;
21
+ /**
22
+ * The mode of sync to use for the collection.
23
+ * @default `eager`
24
+ */
25
+ syncMode?: TrailBaseSyncMode;
20
26
  parse: Conversions<TRecord, TItem>;
21
27
  serialize: Conversions<TItem, TRecord>;
22
28
  }
@@ -1,5 +1,5 @@
1
1
  import { RecordApi } from 'trailbase';
2
- import { BaseCollectionConfig, CollectionConfig, UtilsRecord } from '@tanstack/db';
2
+ import { BaseCollectionConfig, CollectionConfig, SyncMode, UtilsRecord } from '@tanstack/db';
3
3
  type ShapeOf<T> = Record<keyof T, unknown>;
4
4
  type Conversion<I, O> = (value: I) => O;
5
5
  type OptionalConversions<InputType extends ShapeOf<OutputType>, OutputType extends ShapeOf<InputType>> = {
@@ -9,14 +9,20 @@ type RequiredConversions<InputType extends ShapeOf<OutputType>, OutputType exten
9
9
  [K in keyof InputType as InputType[K] extends OutputType[K] ? never : K]: Conversion<InputType[K], OutputType[K]>;
10
10
  };
11
11
  type Conversions<InputType extends ShapeOf<OutputType>, OutputType extends ShapeOf<InputType>> = OptionalConversions<InputType, OutputType> & RequiredConversions<InputType, OutputType>;
12
+ export type TrailBaseSyncMode = SyncMode;
12
13
  /**
13
14
  * Configuration interface for Trailbase Collection
14
15
  */
15
- export interface TrailBaseCollectionConfig<TItem extends ShapeOf<TRecord>, TRecord extends ShapeOf<TItem> = TItem, TKey extends string | number = string | number> extends Omit<BaseCollectionConfig<TItem, TKey>, `onInsert` | `onUpdate` | `onDelete`> {
16
+ export interface TrailBaseCollectionConfig<TItem extends ShapeOf<TRecord>, TRecord extends ShapeOf<TItem> = TItem, TKey extends string | number = string | number> extends Omit<BaseCollectionConfig<TItem, TKey>, `onInsert` | `onUpdate` | `onDelete` | `syncMode`> {
16
17
  /**
17
18
  * Record API name
18
19
  */
19
20
  recordApi: RecordApi<TRecord>;
21
+ /**
22
+ * The mode of sync to use for the collection.
23
+ * @default `eager`
24
+ */
25
+ syncMode?: TrailBaseSyncMode;
20
26
  parse: Conversions<TRecord, TItem>;
21
27
  serialize: Conversions<TItem, TRecord>;
22
28
  }
@@ -24,6 +24,8 @@ function trailBaseCollectionOptions(config) {
24
24
  const serialUpd = (item) => convertPartial(config.serialize, item);
25
25
  const serialIns = (item) => convert(config.serialize, item);
26
26
  const seenIds = new Store(/* @__PURE__ */ new Map());
27
+ const internalSyncMode = config.syncMode ?? `eager`;
28
+ let fullSyncCompleted = false;
27
29
  const awaitIds = (ids, timeout = 120 * 1e3) => {
28
30
  const completed = (value) => ids.every((id) => value.has(id));
29
31
  if (completed(seenIds.state)) {
@@ -31,13 +33,13 @@ function trailBaseCollectionOptions(config) {
31
33
  }
32
34
  return new Promise((resolve, reject) => {
33
35
  const timeoutId = setTimeout(() => {
34
- unsubscribe();
36
+ sub.unsubscribe();
35
37
  reject(new TimeoutWaitingForIdsError(ids.toString()));
36
38
  }, timeout);
37
- const unsubscribe = seenIds.subscribe((value) => {
38
- if (completed(value.currentVal)) {
39
+ const sub = seenIds.subscribe((value) => {
40
+ if (completed(value)) {
39
41
  clearTimeout(timeoutId);
40
- unsubscribe();
42
+ sub.unsubscribe();
41
43
  resolve();
42
44
  }
43
45
  });
@@ -54,37 +56,59 @@ function trailBaseCollectionOptions(config) {
54
56
  const sync = {
55
57
  sync: (params) => {
56
58
  const { begin, write, commit, markReady } = params;
57
- async function initialFetch() {
58
- const limit = 256;
59
- let response = await config.recordApi.list({
60
- pagination: {
61
- limit
62
- }
63
- });
64
- let cursor = response.cursor;
65
- let got = 0;
66
- begin();
59
+ const cursors = /* @__PURE__ */ new Map();
60
+ async function load(opts) {
61
+ const lastKey = opts.cursor?.lastKey;
62
+ let cursor = lastKey !== void 0 ? cursors.get(lastKey) : void 0;
63
+ let offset = (opts.offset ?? 0) > 0 ? opts.offset : void 0;
64
+ const order = buildOrder(opts);
65
+ const filters = buildFilters(
66
+ opts,
67
+ config
68
+ );
69
+ let remaining = opts.limit ?? Number.MAX_VALUE;
70
+ if (remaining <= 0) {
71
+ return;
72
+ }
67
73
  while (true) {
74
+ const limit = Math.min(remaining, 256);
75
+ const response = await config.recordApi.list({
76
+ pagination: {
77
+ limit,
78
+ offset,
79
+ cursor
80
+ },
81
+ order,
82
+ filters
83
+ });
68
84
  const length = response.records.length;
69
- if (length === 0) break;
70
- got = got + length;
71
- for (const item of response.records) {
85
+ if (length === 0) {
86
+ break;
87
+ }
88
+ begin();
89
+ for (let i = 0; i < Math.min(length, remaining); ++i) {
72
90
  write({
73
91
  type: `insert`,
74
- value: parse(item)
92
+ value: parse(response.records[i])
75
93
  });
76
94
  }
77
- if (length < limit) break;
78
- response = await config.recordApi.list({
79
- pagination: {
80
- limit,
81
- cursor,
82
- offset: cursor === void 0 ? got : void 0
95
+ commit();
96
+ remaining -= length;
97
+ if (length < limit || remaining <= 0) {
98
+ if (response.cursor) {
99
+ cursors.set(
100
+ getKey(parse(response.records.at(-1))),
101
+ response.cursor
102
+ );
83
103
  }
84
- });
85
- cursor = response.cursor;
104
+ break;
105
+ }
106
+ if (offset !== void 0) {
107
+ offset += length;
108
+ } else {
109
+ cursor = response.cursor;
110
+ }
86
111
  }
87
- commit();
88
112
  }
89
113
  async function listen(reader) {
90
114
  while (true) {
@@ -123,7 +147,10 @@ function trailBaseCollectionOptions(config) {
123
147
  const reader = eventReader = eventStream.getReader();
124
148
  listen(reader);
125
149
  try {
126
- await initialFetch();
150
+ if (internalSyncMode === `eager`) {
151
+ await load({});
152
+ fullSyncCompleted = true;
153
+ }
127
154
  } catch (e) {
128
155
  cancelEventReader();
129
156
  throw e;
@@ -148,9 +175,21 @@ function trailBaseCollectionOptions(config) {
148
175
  reader.closed.finally(() => clearInterval(periodicCleanupTask));
149
176
  }
150
177
  start();
178
+ if (internalSyncMode === `eager`) {
179
+ return;
180
+ }
181
+ return {
182
+ loadSubset: load,
183
+ getSyncMetadata: () => ({
184
+ syncMode: internalSyncMode
185
+ })
186
+ };
151
187
  },
152
188
  // Expose the getSyncMetadata function
153
- getSyncMetadata: void 0
189
+ getSyncMetadata: () => ({
190
+ syncMode: internalSyncMode,
191
+ fullSyncComplete: fullSyncCompleted
192
+ })
154
193
  };
155
194
  return {
156
195
  ...config,
@@ -200,6 +239,86 @@ function trailBaseCollectionOptions(config) {
200
239
  }
201
240
  };
202
241
  }
242
+ function buildOrder(opts) {
243
+ return opts.orderBy?.map((o) => {
244
+ switch (o.expression.type) {
245
+ case "ref": {
246
+ const field = o.expression.path[0];
247
+ if (o.compareOptions.direction == "asc") {
248
+ return `+${field}`;
249
+ }
250
+ return `-${field}`;
251
+ }
252
+ default: {
253
+ console.warn(
254
+ "Skipping unsupported order clause:",
255
+ JSON.stringify(o.expression)
256
+ );
257
+ return void 0;
258
+ }
259
+ }
260
+ }).filter((f) => f !== void 0);
261
+ }
262
+ function buildCompareOp(name) {
263
+ switch (name) {
264
+ case "eq":
265
+ return "equal";
266
+ case "ne":
267
+ return "notEqual";
268
+ case "gt":
269
+ return "greaterThan";
270
+ case "gte":
271
+ return "greaterThanEqual";
272
+ case "lt":
273
+ return "lessThan";
274
+ case "lte":
275
+ return "lessThanEqual";
276
+ default:
277
+ return void 0;
278
+ }
279
+ }
280
+ function buildFilters(opts, config) {
281
+ const where = opts.where;
282
+ if (where === void 0) {
283
+ return void 0;
284
+ }
285
+ function serializeValue(column, value) {
286
+ const conv = config.serialize[column];
287
+ if (conv) {
288
+ return `${conv(value)}`;
289
+ }
290
+ if (typeof value === "boolean") {
291
+ return value ? "1" : "0";
292
+ }
293
+ return `${value}`;
294
+ }
295
+ switch (where.type) {
296
+ case "func": {
297
+ const field = where.args[0];
298
+ const val = where.args[1];
299
+ const op = buildCompareOp(where.name);
300
+ if (op === void 0) {
301
+ break;
302
+ }
303
+ if (field?.type === "ref" && val?.type === "val") {
304
+ const column = field.path.at(0);
305
+ if (column) {
306
+ const f = [
307
+ {
308
+ column: field.path.at(0) ?? "",
309
+ op,
310
+ value: serializeValue(column, val.value)
311
+ }
312
+ ];
313
+ return f;
314
+ }
315
+ }
316
+ break;
317
+ }
318
+ }
319
+ console.warn("where clause which is not (yet) supported", opts.where);
320
+ return void 0;
321
+ }
203
322
  export {
204
323
  trailBaseCollectionOptions
205
324
  };
@@ -1 +1 @@
1
- {"version":3,"file":"trailbase.js","sources":["../../src/trailbase.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-unnecessary-condition */\nimport { Store } from '@tanstack/store'\nimport {\n ExpectedDeleteTypeError,\n ExpectedInsertTypeError,\n ExpectedUpdateTypeError,\n TimeoutWaitingForIdsError,\n} from './errors'\nimport type { Event, RecordApi } from 'trailbase'\n\nimport type {\n BaseCollectionConfig,\n CollectionConfig,\n DeleteMutationFnParams,\n InsertMutationFnParams,\n SyncConfig,\n UpdateMutationFnParams,\n UtilsRecord,\n} from '@tanstack/db'\n\ntype ShapeOf<T> = Record<keyof T, unknown>\ntype Conversion<I, O> = (value: I) => O\n\ntype OptionalConversions<\n InputType extends ShapeOf<OutputType>,\n OutputType extends ShapeOf<InputType>,\n> = {\n // Excludes all keys that require a conversation.\n [K in keyof InputType as InputType[K] extends OutputType[K]\n ? K\n : never]?: Conversion<InputType[K], OutputType[K]>\n}\n\ntype RequiredConversions<\n InputType extends ShapeOf<OutputType>,\n OutputType extends ShapeOf<InputType>,\n> = {\n // Excludes all keys that do not strictly require a conversation.\n [K in keyof InputType as InputType[K] extends OutputType[K]\n ? never\n : K]: Conversion<InputType[K], OutputType[K]>\n}\n\ntype Conversions<\n InputType extends ShapeOf<OutputType>,\n OutputType extends ShapeOf<InputType>,\n> = OptionalConversions<InputType, OutputType> &\n RequiredConversions<InputType, OutputType>\n\nfunction convert<\n InputType extends ShapeOf<OutputType> & Record<string, unknown>,\n OutputType extends ShapeOf<InputType>,\n>(\n conversions: Conversions<InputType, OutputType>,\n input: InputType,\n): OutputType {\n const c = conversions as Record<string, Conversion<InputType, OutputType>>\n\n return Object.fromEntries(\n Object.keys(input).map((k: string) => {\n const value = input[k]\n return [k, c[k]?.(value as any) ?? value]\n }),\n ) as OutputType\n}\n\nfunction convertPartial<\n InputType extends ShapeOf<OutputType> & Record<string, unknown>,\n OutputType extends ShapeOf<InputType>,\n>(\n conversions: Conversions<InputType, OutputType>,\n input: Partial<InputType>,\n): Partial<OutputType> {\n const c = conversions as Record<string, Conversion<InputType, OutputType>>\n\n return Object.fromEntries(\n Object.keys(input).map((k: string) => {\n const value = input[k]\n return [k, c[k]?.(value as any) ?? value]\n }),\n ) as OutputType\n}\n\n/**\n * Configuration interface for Trailbase Collection\n */\nexport interface TrailBaseCollectionConfig<\n TItem extends ShapeOf<TRecord>,\n TRecord extends ShapeOf<TItem> = TItem,\n TKey extends string | number = string | number,\n> extends Omit<\n BaseCollectionConfig<TItem, TKey>,\n `onInsert` | `onUpdate` | `onDelete`\n> {\n /**\n * Record API name\n */\n recordApi: RecordApi<TRecord>\n\n parse: Conversions<TRecord, TItem>\n serialize: Conversions<TItem, TRecord>\n}\n\nexport type AwaitTxIdFn = (txId: string, timeout?: number) => Promise<boolean>\n\nexport interface TrailBaseCollectionUtils extends UtilsRecord {\n cancel: () => void\n}\n\nexport function trailBaseCollectionOptions<\n TItem extends ShapeOf<TRecord>,\n TRecord extends ShapeOf<TItem> = TItem,\n TKey extends string | number = string | number,\n>(\n config: TrailBaseCollectionConfig<TItem, TRecord, TKey>,\n): CollectionConfig<TItem, TKey, never, TrailBaseCollectionUtils> & {\n utils: TrailBaseCollectionUtils\n} {\n const getKey = config.getKey\n\n const parse = (record: TRecord) =>\n convert<TRecord, TItem>(config.parse, record)\n const serialUpd = (item: Partial<TItem>) =>\n convertPartial<TItem, TRecord>(config.serialize, item)\n const serialIns = (item: TItem) =>\n convert<TItem, TRecord>(config.serialize, item)\n\n const seenIds = new Store(new Map<string, number>())\n\n const awaitIds = (\n ids: Array<string>,\n timeout: number = 120 * 1000,\n ): Promise<void> => {\n const completed = (value: Map<string, number>) =>\n ids.every((id) => value.has(id))\n if (completed(seenIds.state)) {\n return Promise.resolve()\n }\n\n return new Promise<void>((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n unsubscribe()\n reject(new TimeoutWaitingForIdsError(ids.toString()))\n }, timeout)\n\n const unsubscribe = seenIds.subscribe((value) => {\n if (completed(value.currentVal)) {\n clearTimeout(timeoutId)\n unsubscribe()\n resolve()\n }\n })\n })\n }\n\n let eventReader: ReadableStreamDefaultReader<Event> | undefined\n const cancelEventReader = () => {\n if (eventReader) {\n eventReader.cancel()\n eventReader.releaseLock()\n eventReader = undefined\n }\n }\n\n type SyncParams = Parameters<SyncConfig<TItem, TKey>[`sync`]>[0]\n const sync = {\n sync: (params: SyncParams) => {\n const { begin, write, commit, markReady } = params\n\n // Initial fetch.\n async function initialFetch() {\n const limit = 256\n let response = await config.recordApi.list({\n pagination: {\n limit,\n },\n })\n let cursor = response.cursor\n let got = 0\n\n begin()\n\n while (true) {\n const length = response.records.length\n if (length === 0) break\n\n got = got + length\n for (const item of response.records) {\n write({\n type: `insert`,\n value: parse(item),\n })\n }\n\n if (length < limit) break\n\n response = await config.recordApi.list({\n pagination: {\n limit,\n cursor,\n offset: cursor === undefined ? got : undefined,\n },\n })\n cursor = response.cursor\n }\n\n commit()\n }\n\n // Afterwards subscribe.\n async function listen(reader: ReadableStreamDefaultReader<Event>) {\n while (true) {\n const { done, value: event } = await reader.read()\n\n if (done || !event) {\n reader.releaseLock()\n eventReader = undefined\n return\n }\n\n begin()\n let value: TItem | undefined\n if (`Insert` in event) {\n value = parse(event.Insert as TRecord)\n write({ type: `insert`, value })\n } else if (`Delete` in event) {\n value = parse(event.Delete as TRecord)\n write({ type: `delete`, value })\n } else if (`Update` in event) {\n value = parse(event.Update as TRecord)\n write({ type: `update`, value })\n } else {\n console.error(`Error: ${event.Error}`)\n }\n commit()\n\n if (value) {\n seenIds.setState((curr: Map<string, number>) => {\n const newIds = new Map(curr)\n newIds.set(String(getKey(value)), Date.now())\n return newIds\n })\n }\n }\n }\n\n async function start() {\n const eventStream = await config.recordApi.subscribe(`*`)\n const reader = (eventReader = eventStream.getReader())\n\n // Start listening for subscriptions first. Otherwise, we'd risk a gap\n // between the initial fetch and starting to listen.\n listen(reader)\n\n try {\n await initialFetch()\n } catch (e) {\n cancelEventReader()\n throw e\n } finally {\n // Mark ready both if everything went well or if there's an error to\n // avoid blocking apps waiting for `.preload()` to finish.\n markReady()\n }\n\n // Lastly, start a periodic cleanup task that will be removed when the\n // reader closes.\n const periodicCleanupTask = setInterval(() => {\n seenIds.setState((curr) => {\n const now = Date.now()\n let anyExpired = false\n\n const notExpired = Array.from(curr.entries()).filter(([_, v]) => {\n const expired = now - v > 300 * 1000\n anyExpired = anyExpired || expired\n return !expired\n })\n\n if (anyExpired) {\n return new Map(notExpired)\n }\n return curr\n })\n }, 120 * 1000)\n\n reader.closed.finally(() => clearInterval(periodicCleanupTask))\n }\n\n start()\n },\n // Expose the getSyncMetadata function\n getSyncMetadata: undefined,\n }\n\n return {\n ...config,\n sync,\n getKey,\n onInsert: async (\n params: InsertMutationFnParams<TItem, TKey>,\n ): Promise<Array<number | string>> => {\n const ids = await config.recordApi.createBulk(\n params.transaction.mutations.map((tx) => {\n const { type, modified } = tx\n if (type !== `insert`) {\n throw new ExpectedInsertTypeError(type)\n }\n return serialIns(modified)\n }),\n )\n\n // The optimistic mutation overlay is removed on return, so at this point\n // we have to ensure that the new record was properly added to the local\n // DB by the subscription.\n await awaitIds(ids.map((id) => String(id)))\n\n return ids\n },\n onUpdate: async (params: UpdateMutationFnParams<TItem, TKey>) => {\n const ids: Array<string> = await Promise.all(\n params.transaction.mutations.map(async (tx) => {\n const { type, changes, key } = tx\n if (type !== `update`) {\n throw new ExpectedUpdateTypeError(type)\n }\n\n await config.recordApi.update(key, serialUpd(changes))\n\n return String(key)\n }),\n )\n\n // The optimistic mutation overlay is removed on return, so at this point\n // we have to ensure that the new record was properly updated in the local\n // DB by the subscription.\n await awaitIds(ids)\n },\n onDelete: async (params: DeleteMutationFnParams<TItem, TKey>) => {\n const ids: Array<string> = await Promise.all(\n params.transaction.mutations.map(async (tx) => {\n const { type, key } = tx\n if (type !== `delete`) {\n throw new ExpectedDeleteTypeError(type)\n }\n\n await config.recordApi.delete(key)\n return String(key)\n }),\n )\n\n // The optimistic mutation overlay is removed on return, so at this point\n // we have to ensure that the new record was properly updated in the local\n // DB by the subscription.\n await awaitIds(ids)\n },\n utils: {\n cancel: cancelEventReader,\n },\n }\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"trailbase.js","sources":["../../src/trailbase.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-unnecessary-condition */\nimport { Store } from '@tanstack/store'\nimport {\n ExpectedDeleteTypeError,\n ExpectedInsertTypeError,\n ExpectedUpdateTypeError,\n TimeoutWaitingForIdsError,\n} from './errors'\nimport type { OrderByClause } from '../../db/dist/esm/query/ir'\nimport type { CompareOp, Event, FilterOrComposite, RecordApi } from 'trailbase'\n\nimport type {\n BaseCollectionConfig,\n CollectionConfig,\n DeleteMutationFnParams,\n InsertMutationFnParams,\n LoadSubsetOptions,\n SyncConfig,\n SyncMode,\n UpdateMutationFnParams,\n UtilsRecord,\n} from '@tanstack/db'\n\ntype ShapeOf<T> = Record<keyof T, unknown>\ntype Conversion<I, O> = (value: I) => O\n\ntype OptionalConversions<\n InputType extends ShapeOf<OutputType>,\n OutputType extends ShapeOf<InputType>,\n> = {\n // Excludes all keys that require a conversation.\n [K in keyof InputType as InputType[K] extends OutputType[K]\n ? K\n : never]?: Conversion<InputType[K], OutputType[K]>\n}\n\ntype RequiredConversions<\n InputType extends ShapeOf<OutputType>,\n OutputType extends ShapeOf<InputType>,\n> = {\n // Excludes all keys that do not strictly require a conversation.\n [K in keyof InputType as InputType[K] extends OutputType[K]\n ? never\n : K]: Conversion<InputType[K], OutputType[K]>\n}\n\ntype Conversions<\n InputType extends ShapeOf<OutputType>,\n OutputType extends ShapeOf<InputType>,\n> = OptionalConversions<InputType, OutputType> &\n RequiredConversions<InputType, OutputType>\n\nfunction convert<\n InputType extends ShapeOf<OutputType> & Record<string, unknown>,\n OutputType extends ShapeOf<InputType>,\n>(\n conversions: Conversions<InputType, OutputType>,\n input: InputType,\n): OutputType {\n const c = conversions as Record<string, Conversion<InputType, OutputType>>\n\n return Object.fromEntries(\n Object.keys(input).map((k: string) => {\n const value = input[k]\n return [k, c[k]?.(value as any) ?? value]\n }),\n ) as OutputType\n}\n\nfunction convertPartial<\n InputType extends ShapeOf<OutputType> & Record<string, unknown>,\n OutputType extends ShapeOf<InputType>,\n>(\n conversions: Conversions<InputType, OutputType>,\n input: Partial<InputType>,\n): Partial<OutputType> {\n const c = conversions as Record<string, Conversion<InputType, OutputType>>\n\n return Object.fromEntries(\n Object.keys(input).map((k: string) => {\n const value = input[k]\n return [k, c[k]?.(value as any) ?? value]\n }),\n ) as OutputType\n}\n\nexport type TrailBaseSyncMode = SyncMode\n\n/**\n * Configuration interface for Trailbase Collection\n */\nexport interface TrailBaseCollectionConfig<\n TItem extends ShapeOf<TRecord>,\n TRecord extends ShapeOf<TItem> = TItem,\n TKey extends string | number = string | number,\n> extends Omit<\n BaseCollectionConfig<TItem, TKey>,\n `onInsert` | `onUpdate` | `onDelete` | `syncMode`\n> {\n /**\n * Record API name\n */\n recordApi: RecordApi<TRecord>\n\n /**\n * The mode of sync to use for the collection.\n * @default `eager`\n */\n syncMode?: TrailBaseSyncMode\n\n parse: Conversions<TRecord, TItem>\n serialize: Conversions<TItem, TRecord>\n}\n\nexport type AwaitTxIdFn = (txId: string, timeout?: number) => Promise<boolean>\n\nexport interface TrailBaseCollectionUtils extends UtilsRecord {\n cancel: () => void\n}\n\nexport function trailBaseCollectionOptions<\n TItem extends ShapeOf<TRecord>,\n TRecord extends ShapeOf<TItem> = TItem,\n TKey extends string | number = string | number,\n>(\n config: TrailBaseCollectionConfig<TItem, TRecord, TKey>,\n): CollectionConfig<TItem, TKey, never, TrailBaseCollectionUtils> & {\n utils: TrailBaseCollectionUtils\n} {\n const getKey = config.getKey\n\n const parse = (record: TRecord) =>\n convert<TRecord, TItem>(config.parse, record)\n const serialUpd = (item: Partial<TItem>) =>\n convertPartial<TItem, TRecord>(config.serialize, item)\n const serialIns = (item: TItem) =>\n convert<TItem, TRecord>(config.serialize, item)\n\n const seenIds = new Store(new Map<string, number>())\n\n const internalSyncMode = config.syncMode ?? `eager`\n let fullSyncCompleted = false\n\n const awaitIds = (\n ids: Array<string>,\n timeout: number = 120 * 1000,\n ): Promise<void> => {\n const completed = (value: Map<string, number>) =>\n ids.every((id) => value.has(id))\n if (completed(seenIds.state)) {\n return Promise.resolve()\n }\n\n return new Promise<void>((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n sub.unsubscribe()\n reject(new TimeoutWaitingForIdsError(ids.toString()))\n }, timeout)\n\n const sub = seenIds.subscribe((value) => {\n if (completed(value)) {\n clearTimeout(timeoutId)\n sub.unsubscribe()\n resolve()\n }\n })\n })\n }\n\n let eventReader: ReadableStreamDefaultReader<Event> | undefined\n const cancelEventReader = () => {\n if (eventReader) {\n eventReader.cancel()\n eventReader.releaseLock()\n eventReader = undefined\n }\n }\n\n type SyncParams = Parameters<SyncConfig<TItem, TKey>[`sync`]>[0]\n const sync = {\n sync: (params: SyncParams) => {\n const { begin, write, commit, markReady } = params\n\n // NOTE: We cache cursors from prior fetches. TanStack/db expects that\n // cursors can be derived from a key, which is not true for TB, since\n // cursors are encrypted. This is leaky and therefore not ideal.\n const cursors = new Map<string | number, string>()\n\n // Load (more) data.\n async function load(opts: LoadSubsetOptions) {\n const lastKey = opts.cursor?.lastKey\n let cursor: string | undefined =\n lastKey !== undefined ? cursors.get(lastKey) : undefined\n let offset: number | undefined =\n (opts.offset ?? 0) > 0 ? opts.offset : undefined\n\n const order: Array<string> | undefined = buildOrder(opts)\n const filters: Array<FilterOrComposite> | undefined = buildFilters(\n opts,\n config,\n )\n\n let remaining: number = opts.limit ?? Number.MAX_VALUE\n if (remaining <= 0) {\n return\n }\n\n while (true) {\n const limit = Math.min(remaining, 256)\n const response = await config.recordApi.list({\n pagination: {\n limit,\n offset,\n cursor,\n },\n order,\n filters,\n })\n\n const length = response.records.length\n if (length === 0) {\n // Drained - read everything.\n break\n }\n\n begin()\n\n for (let i = 0; i < Math.min(length, remaining); ++i) {\n write({\n type: `insert`,\n value: parse(response.records[i]!),\n })\n }\n\n commit()\n\n remaining -= length\n\n // Drained or read enough.\n if (length < limit || remaining <= 0) {\n if (response.cursor) {\n cursors.set(\n getKey(parse(response.records.at(-1)!)),\n response.cursor,\n )\n }\n break\n }\n\n // Update params for next iteration.\n if (offset !== undefined) {\n offset += length\n } else {\n cursor = response.cursor\n }\n }\n }\n\n // Afterwards subscribe.\n async function listen(reader: ReadableStreamDefaultReader<Event>) {\n while (true) {\n const { done, value: event } = await reader.read()\n\n if (done || !event) {\n reader.releaseLock()\n eventReader = undefined\n return\n }\n\n begin()\n let value: TItem | undefined\n if (`Insert` in event) {\n value = parse(event.Insert as TRecord)\n write({ type: `insert`, value })\n } else if (`Delete` in event) {\n value = parse(event.Delete as TRecord)\n write({ type: `delete`, value })\n } else if (`Update` in event) {\n value = parse(event.Update as TRecord)\n write({ type: `update`, value })\n } else {\n console.error(`Error: ${event.Error}`)\n }\n commit()\n\n if (value) {\n seenIds.setState((curr: Map<string, number>) => {\n const newIds = new Map(curr)\n newIds.set(String(getKey(value)), Date.now())\n return newIds\n })\n }\n }\n }\n\n async function start() {\n const eventStream = await config.recordApi.subscribe(`*`)\n const reader = (eventReader = eventStream.getReader())\n\n // Start listening for subscriptions first. Otherwise, we'd risk a gap\n // between the initial fetch and starting to listen.\n listen(reader)\n\n try {\n // Eager mode: perform initial fetch to populate everything\n if (internalSyncMode === `eager`) {\n // Load everything on initial load.\n await load({})\n fullSyncCompleted = true\n }\n } catch (e) {\n cancelEventReader()\n throw e\n } finally {\n // Mark ready both if everything went well or if there's an error to\n // avoid blocking apps waiting for `.preload()` to finish.\n markReady()\n }\n\n // Lastly, start a periodic cleanup task that will be removed when the\n // reader closes.\n const periodicCleanupTask = setInterval(() => {\n seenIds.setState((curr) => {\n const now = Date.now()\n let anyExpired = false\n\n const notExpired = Array.from(curr.entries()).filter(([_, v]) => {\n const expired = now - v > 300 * 1000\n anyExpired = anyExpired || expired\n return !expired\n })\n\n if (anyExpired) {\n return new Map(notExpired)\n }\n return curr\n })\n }, 120 * 1000)\n\n reader.closed.finally(() => clearInterval(periodicCleanupTask))\n }\n\n start()\n\n // Eager mode doesn't need subset loading\n if (internalSyncMode === `eager`) {\n return\n }\n\n return {\n loadSubset: load,\n getSyncMetadata: () =>\n ({\n syncMode: internalSyncMode,\n }) as const,\n }\n },\n // Expose the getSyncMetadata function\n getSyncMetadata: () =>\n ({\n syncMode: internalSyncMode,\n fullSyncComplete: fullSyncCompleted,\n }) as const,\n }\n\n return {\n ...config,\n sync,\n getKey,\n onInsert: async (\n params: InsertMutationFnParams<TItem, TKey>,\n ): Promise<Array<number | string>> => {\n const ids = await config.recordApi.createBulk(\n params.transaction.mutations.map((tx) => {\n const { type, modified } = tx\n if (type !== `insert`) {\n throw new ExpectedInsertTypeError(type)\n }\n return serialIns(modified)\n }),\n )\n\n // The optimistic mutation overlay is removed on return, so at this point\n // we have to ensure that the new record was properly added to the local\n // DB by the subscription.\n await awaitIds(ids.map((id) => String(id)))\n\n return ids\n },\n onUpdate: async (params: UpdateMutationFnParams<TItem, TKey>) => {\n const ids: Array<string> = await Promise.all(\n params.transaction.mutations.map(async (tx) => {\n const { type, changes, key } = tx\n if (type !== `update`) {\n throw new ExpectedUpdateTypeError(type)\n }\n\n await config.recordApi.update(key, serialUpd(changes))\n\n return String(key)\n }),\n )\n\n // The optimistic mutation overlay is removed on return, so at this point\n // we have to ensure that the new record was properly updated in the local\n // DB by the subscription.\n await awaitIds(ids)\n },\n onDelete: async (params: DeleteMutationFnParams<TItem, TKey>) => {\n const ids: Array<string> = await Promise.all(\n params.transaction.mutations.map(async (tx) => {\n const { type, key } = tx\n if (type !== `delete`) {\n throw new ExpectedDeleteTypeError(type)\n }\n\n await config.recordApi.delete(key)\n return String(key)\n }),\n )\n\n // The optimistic mutation overlay is removed on return, so at this point\n // we have to ensure that the new record was properly updated in the local\n // DB by the subscription.\n await awaitIds(ids)\n },\n utils: {\n cancel: cancelEventReader,\n },\n }\n}\n\nfunction buildOrder(opts: LoadSubsetOptions): undefined | Array<string> {\n return opts.orderBy\n ?.map((o: OrderByClause) => {\n switch (o.expression.type) {\n case 'ref': {\n const field = o.expression.path[0]\n if (o.compareOptions.direction == 'asc') {\n return `+${field}`\n }\n return `-${field}`\n }\n default: {\n console.warn(\n 'Skipping unsupported order clause:',\n JSON.stringify(o.expression),\n )\n return undefined\n }\n }\n })\n .filter((f: string | undefined) => f !== undefined)\n}\n\nfunction buildCompareOp(name: string): CompareOp | undefined {\n switch (name) {\n case 'eq':\n return 'equal'\n case 'ne':\n return 'notEqual'\n case 'gt':\n return 'greaterThan'\n case 'gte':\n return 'greaterThanEqual'\n case 'lt':\n return 'lessThan'\n case 'lte':\n return 'lessThanEqual'\n default:\n return undefined\n }\n}\n\nfunction buildFilters<\n TItem extends ShapeOf<TRecord>,\n TRecord extends ShapeOf<TItem> = TItem,\n TKey extends string | number = string | number,\n>(\n opts: LoadSubsetOptions,\n config: TrailBaseCollectionConfig<TItem, TRecord, TKey>,\n): undefined | Array<FilterOrComposite> {\n const where = opts.where\n if (where === undefined) {\n return undefined\n }\n\n function serializeValue<T = any>(column: string, value: T): string {\n const conv = (config.serialize as any)[column]\n if (conv) {\n return `${conv(value)}`\n }\n\n if (typeof value === 'boolean') {\n return value ? '1' : '0'\n }\n\n return `${value}`\n }\n\n switch (where.type) {\n case 'func': {\n const field = where.args[0]\n const val = where.args[1]\n\n const op = buildCompareOp(where.name)\n if (op === undefined) {\n break\n }\n\n if (field?.type === 'ref' && val?.type === 'val') {\n const column = field.path.at(0)\n if (column) {\n const f = [\n {\n column: field.path.at(0) ?? '',\n op,\n value: serializeValue(column, val.value),\n },\n ]\n\n return f\n }\n }\n break\n }\n case 'ref':\n case 'val':\n break\n }\n\n console.warn('where clause which is not (yet) supported', opts.where)\n\n return undefined\n}\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/trailbase-db-collection",
3
- "version": "0.1.77",
3
+ "version": "0.1.79",
4
4
  "description": "TrailBase collection for TanStack DB",
5
5
  "author": "Sebastian Jeltsch",
6
6
  "license": "MIT",
@@ -40,22 +40,24 @@
40
40
  ],
41
41
  "dependencies": {
42
42
  "@standard-schema/spec": "^1.1.0",
43
- "@tanstack/store": "^0.8.0",
43
+ "@tanstack/store": "^0.9.2",
44
44
  "debug": "^4.4.3",
45
- "trailbase": "^0.8.0",
46
- "@tanstack/db": "0.5.33"
45
+ "trailbase": "^0.10.0",
46
+ "@tanstack/db": "0.6.1"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "typescript": ">=4.7"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/debug": "^4.1.12",
53
- "@vitest/coverage-istanbul": "^3.2.4"
53
+ "@vitest/coverage-istanbul": "^3.2.4",
54
+ "uuid": "^13.0.0"
54
55
  },
55
56
  "scripts": {
56
57
  "build": "vite build",
57
58
  "dev": "vite build --watch",
58
59
  "lint": "eslint . --fix",
59
- "test": "vitest --run"
60
+ "test": "vitest --run",
61
+ "test:e2e": "vitest --run --config vitest.e2e.config.ts"
60
62
  }
61
63
  }
package/src/trailbase.ts CHANGED
@@ -6,14 +6,17 @@ import {
6
6
  ExpectedUpdateTypeError,
7
7
  TimeoutWaitingForIdsError,
8
8
  } from './errors'
9
- import type { Event, RecordApi } from 'trailbase'
9
+ import type { OrderByClause } from '../../db/dist/esm/query/ir'
10
+ import type { CompareOp, Event, FilterOrComposite, RecordApi } from 'trailbase'
10
11
 
11
12
  import type {
12
13
  BaseCollectionConfig,
13
14
  CollectionConfig,
14
15
  DeleteMutationFnParams,
15
16
  InsertMutationFnParams,
17
+ LoadSubsetOptions,
16
18
  SyncConfig,
19
+ SyncMode,
17
20
  UpdateMutationFnParams,
18
21
  UtilsRecord,
19
22
  } from '@tanstack/db'
@@ -81,6 +84,8 @@ function convertPartial<
81
84
  ) as OutputType
82
85
  }
83
86
 
87
+ export type TrailBaseSyncMode = SyncMode
88
+
84
89
  /**
85
90
  * Configuration interface for Trailbase Collection
86
91
  */
@@ -90,13 +95,19 @@ export interface TrailBaseCollectionConfig<
90
95
  TKey extends string | number = string | number,
91
96
  > extends Omit<
92
97
  BaseCollectionConfig<TItem, TKey>,
93
- `onInsert` | `onUpdate` | `onDelete`
98
+ `onInsert` | `onUpdate` | `onDelete` | `syncMode`
94
99
  > {
95
100
  /**
96
101
  * Record API name
97
102
  */
98
103
  recordApi: RecordApi<TRecord>
99
104
 
105
+ /**
106
+ * The mode of sync to use for the collection.
107
+ * @default `eager`
108
+ */
109
+ syncMode?: TrailBaseSyncMode
110
+
100
111
  parse: Conversions<TRecord, TItem>
101
112
  serialize: Conversions<TItem, TRecord>
102
113
  }
@@ -127,6 +138,9 @@ export function trailBaseCollectionOptions<
127
138
 
128
139
  const seenIds = new Store(new Map<string, number>())
129
140
 
141
+ const internalSyncMode = config.syncMode ?? `eager`
142
+ let fullSyncCompleted = false
143
+
130
144
  const awaitIds = (
131
145
  ids: Array<string>,
132
146
  timeout: number = 120 * 1000,
@@ -139,14 +153,14 @@ export function trailBaseCollectionOptions<
139
153
 
140
154
  return new Promise<void>((resolve, reject) => {
141
155
  const timeoutId = setTimeout(() => {
142
- unsubscribe()
156
+ sub.unsubscribe()
143
157
  reject(new TimeoutWaitingForIdsError(ids.toString()))
144
158
  }, timeout)
145
159
 
146
- const unsubscribe = seenIds.subscribe((value) => {
147
- if (completed(value.currentVal)) {
160
+ const sub = seenIds.subscribe((value) => {
161
+ if (completed(value)) {
148
162
  clearTimeout(timeoutId)
149
- unsubscribe()
163
+ sub.unsubscribe()
150
164
  resolve()
151
165
  }
152
166
  })
@@ -167,44 +181,79 @@ export function trailBaseCollectionOptions<
167
181
  sync: (params: SyncParams) => {
168
182
  const { begin, write, commit, markReady } = params
169
183
 
170
- // Initial fetch.
171
- async function initialFetch() {
172
- const limit = 256
173
- let response = await config.recordApi.list({
174
- pagination: {
175
- limit,
176
- },
177
- })
178
- let cursor = response.cursor
179
- let got = 0
180
-
181
- begin()
184
+ // NOTE: We cache cursors from prior fetches. TanStack/db expects that
185
+ // cursors can be derived from a key, which is not true for TB, since
186
+ // cursors are encrypted. This is leaky and therefore not ideal.
187
+ const cursors = new Map<string | number, string>()
188
+
189
+ // Load (more) data.
190
+ async function load(opts: LoadSubsetOptions) {
191
+ const lastKey = opts.cursor?.lastKey
192
+ let cursor: string | undefined =
193
+ lastKey !== undefined ? cursors.get(lastKey) : undefined
194
+ let offset: number | undefined =
195
+ (opts.offset ?? 0) > 0 ? opts.offset : undefined
196
+
197
+ const order: Array<string> | undefined = buildOrder(opts)
198
+ const filters: Array<FilterOrComposite> | undefined = buildFilters(
199
+ opts,
200
+ config,
201
+ )
202
+
203
+ let remaining: number = opts.limit ?? Number.MAX_VALUE
204
+ if (remaining <= 0) {
205
+ return
206
+ }
182
207
 
183
208
  while (true) {
209
+ const limit = Math.min(remaining, 256)
210
+ const response = await config.recordApi.list({
211
+ pagination: {
212
+ limit,
213
+ offset,
214
+ cursor,
215
+ },
216
+ order,
217
+ filters,
218
+ })
219
+
184
220
  const length = response.records.length
185
- if (length === 0) break
221
+ if (length === 0) {
222
+ // Drained - read everything.
223
+ break
224
+ }
186
225
 
187
- got = got + length
188
- for (const item of response.records) {
226
+ begin()
227
+
228
+ for (let i = 0; i < Math.min(length, remaining); ++i) {
189
229
  write({
190
230
  type: `insert`,
191
- value: parse(item),
231
+ value: parse(response.records[i]!),
192
232
  })
193
233
  }
194
234
 
195
- if (length < limit) break
235
+ commit()
196
236
 
197
- response = await config.recordApi.list({
198
- pagination: {
199
- limit,
200
- cursor,
201
- offset: cursor === undefined ? got : undefined,
202
- },
203
- })
204
- cursor = response.cursor
205
- }
237
+ remaining -= length
206
238
 
207
- commit()
239
+ // Drained or read enough.
240
+ if (length < limit || remaining <= 0) {
241
+ if (response.cursor) {
242
+ cursors.set(
243
+ getKey(parse(response.records.at(-1)!)),
244
+ response.cursor,
245
+ )
246
+ }
247
+ break
248
+ }
249
+
250
+ // Update params for next iteration.
251
+ if (offset !== undefined) {
252
+ offset += length
253
+ } else {
254
+ cursor = response.cursor
255
+ }
256
+ }
208
257
  }
209
258
 
210
259
  // Afterwards subscribe.
@@ -253,7 +302,12 @@ export function trailBaseCollectionOptions<
253
302
  listen(reader)
254
303
 
255
304
  try {
256
- await initialFetch()
305
+ // Eager mode: perform initial fetch to populate everything
306
+ if (internalSyncMode === `eager`) {
307
+ // Load everything on initial load.
308
+ await load({})
309
+ fullSyncCompleted = true
310
+ }
257
311
  } catch (e) {
258
312
  cancelEventReader()
259
313
  throw e
@@ -287,9 +341,26 @@ export function trailBaseCollectionOptions<
287
341
  }
288
342
 
289
343
  start()
344
+
345
+ // Eager mode doesn't need subset loading
346
+ if (internalSyncMode === `eager`) {
347
+ return
348
+ }
349
+
350
+ return {
351
+ loadSubset: load,
352
+ getSyncMetadata: () =>
353
+ ({
354
+ syncMode: internalSyncMode,
355
+ }) as const,
356
+ }
290
357
  },
291
358
  // Expose the getSyncMetadata function
292
- getSyncMetadata: undefined,
359
+ getSyncMetadata: () =>
360
+ ({
361
+ syncMode: internalSyncMode,
362
+ fullSyncComplete: fullSyncCompleted,
363
+ }) as const,
293
364
  }
294
365
 
295
366
  return {
@@ -358,3 +429,107 @@ export function trailBaseCollectionOptions<
358
429
  },
359
430
  }
360
431
  }
432
+
433
+ function buildOrder(opts: LoadSubsetOptions): undefined | Array<string> {
434
+ return opts.orderBy
435
+ ?.map((o: OrderByClause) => {
436
+ switch (o.expression.type) {
437
+ case 'ref': {
438
+ const field = o.expression.path[0]
439
+ if (o.compareOptions.direction == 'asc') {
440
+ return `+${field}`
441
+ }
442
+ return `-${field}`
443
+ }
444
+ default: {
445
+ console.warn(
446
+ 'Skipping unsupported order clause:',
447
+ JSON.stringify(o.expression),
448
+ )
449
+ return undefined
450
+ }
451
+ }
452
+ })
453
+ .filter((f: string | undefined) => f !== undefined)
454
+ }
455
+
456
+ function buildCompareOp(name: string): CompareOp | undefined {
457
+ switch (name) {
458
+ case 'eq':
459
+ return 'equal'
460
+ case 'ne':
461
+ return 'notEqual'
462
+ case 'gt':
463
+ return 'greaterThan'
464
+ case 'gte':
465
+ return 'greaterThanEqual'
466
+ case 'lt':
467
+ return 'lessThan'
468
+ case 'lte':
469
+ return 'lessThanEqual'
470
+ default:
471
+ return undefined
472
+ }
473
+ }
474
+
475
+ function buildFilters<
476
+ TItem extends ShapeOf<TRecord>,
477
+ TRecord extends ShapeOf<TItem> = TItem,
478
+ TKey extends string | number = string | number,
479
+ >(
480
+ opts: LoadSubsetOptions,
481
+ config: TrailBaseCollectionConfig<TItem, TRecord, TKey>,
482
+ ): undefined | Array<FilterOrComposite> {
483
+ const where = opts.where
484
+ if (where === undefined) {
485
+ return undefined
486
+ }
487
+
488
+ function serializeValue<T = any>(column: string, value: T): string {
489
+ const conv = (config.serialize as any)[column]
490
+ if (conv) {
491
+ return `${conv(value)}`
492
+ }
493
+
494
+ if (typeof value === 'boolean') {
495
+ return value ? '1' : '0'
496
+ }
497
+
498
+ return `${value}`
499
+ }
500
+
501
+ switch (where.type) {
502
+ case 'func': {
503
+ const field = where.args[0]
504
+ const val = where.args[1]
505
+
506
+ const op = buildCompareOp(where.name)
507
+ if (op === undefined) {
508
+ break
509
+ }
510
+
511
+ if (field?.type === 'ref' && val?.type === 'val') {
512
+ const column = field.path.at(0)
513
+ if (column) {
514
+ const f = [
515
+ {
516
+ column: field.path.at(0) ?? '',
517
+ op,
518
+ value: serializeValue(column, val.value),
519
+ },
520
+ ]
521
+
522
+ return f
523
+ }
524
+ }
525
+ break
526
+ }
527
+ case 'ref':
528
+ case 'val':
529
+ break
530
+ }
531
+
532
+ console.warn('where clause which is not (yet) supported', opts.where)
533
+
534
+ return undefined
535
+ }