@tanstack/db 0.0.11 → 0.0.12

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.
@@ -18,7 +18,12 @@ class CompiledQuery {
18
18
  const inputs = Object.fromEntries(
19
19
  Object.entries(collections).map(([key]) => [key, graph.newInput()])
20
20
  );
21
- const sync = ({ begin, write, commit }) => {
21
+ const sync = ({
22
+ begin,
23
+ write,
24
+ commit,
25
+ collection
26
+ }) => {
22
27
  compileQueryPipeline(
23
28
  query,
24
29
  inputs
@@ -42,12 +47,17 @@ class CompiledQuery {
42
47
  }, /* @__PURE__ */ new Map()).forEach((changes, rawKey) => {
43
48
  const { deletes, inserts, value } = changes;
44
49
  const valueWithKey = { ...value, _key: rawKey };
45
- if (inserts && !deletes) {
50
+ if (inserts && deletes === 0) {
46
51
  write({
47
52
  value: valueWithKey,
48
53
  type: `insert`
49
54
  });
50
- } else if (inserts >= deletes) {
55
+ } else if (
56
+ // Insert & update(s) (updates are a delete & insert)
57
+ inserts > deletes || // Just update(s) but the item is already in the collection (so
58
+ // was inserted previously).
59
+ inserts === deletes && collection.has(valueWithKey._key)
60
+ ) {
51
61
  write({
52
62
  value: valueWithKey,
53
63
  type: `update`
@@ -57,6 +67,10 @@ class CompiledQuery {
57
67
  value: valueWithKey,
58
68
  type: `delete`
59
69
  });
70
+ } else {
71
+ throw new Error(
72
+ `This should never happen ${JSON.stringify(changes)}`
73
+ );
60
74
  }
61
75
  });
62
76
  commit();
@@ -67,8 +81,6 @@ class CompiledQuery {
67
81
  this.graph = graph;
68
82
  this.inputs = inputs;
69
83
  this.resultCollection = createCollection({
70
- id: crypto.randomUUID(),
71
- // TODO: remove when we don't require any more
72
84
  getKey: (val) => {
73
85
  return val._key;
74
86
  },
@@ -1 +1 @@
1
- {"version":3,"file":"compiled-query.js","sources":["../../../src/query/compiled-query.ts"],"sourcesContent":["import { D2, MultiSet, output } from \"@electric-sql/d2mini\"\nimport { createCollection } from \"../collection.js\"\nimport { compileQueryPipeline } from \"./pipeline-compiler.js\"\nimport type { Collection } from \"../collection.js\"\nimport type { ChangeMessage, SyncConfig } from \"../types.js\"\nimport type {\n IStreamBuilder,\n MultiSetArray,\n RootStreamBuilder,\n} from \"@electric-sql/d2mini\"\nimport type { QueryBuilder, ResultsFromContext } from \"./query-builder.js\"\nimport type { Context, Schema } from \"./types.js\"\n\nexport function compileQuery<TContext extends Context<Schema>>(\n queryBuilder: QueryBuilder<TContext>\n) {\n return new CompiledQuery<\n ResultsFromContext<TContext> & { _key?: string | number }\n >(queryBuilder)\n}\n\nexport class CompiledQuery<TResults extends object = Record<string, unknown>> {\n private graph: D2\n private inputs: Record<string, RootStreamBuilder<any>>\n private inputCollections: Record<string, Collection<any>>\n private resultCollection: Collection<TResults>\n public state: `compiled` | `running` | `stopped` = `compiled`\n private unsubscribeCallbacks: Array<() => void> = []\n\n constructor(queryBuilder: QueryBuilder<Context<Schema>>) {\n const query = queryBuilder._query\n const collections = query.collections\n\n if (!collections) {\n throw new Error(`No collections provided`)\n }\n\n this.inputCollections = collections\n\n const graph = new D2()\n const inputs = Object.fromEntries(\n Object.entries(collections).map(([key]) => [key, graph.newInput<any>()])\n )\n\n const sync: SyncConfig<TResults>[`sync`] = ({ begin, write, commit }) => {\n compileQueryPipeline<IStreamBuilder<[unknown, TResults]>>(\n query,\n inputs\n ).pipe(\n output((data) => {\n begin()\n data\n .getInner()\n .reduce((acc, [[key, value], multiplicity]) => {\n const changes = acc.get(key) || {\n deletes: 0,\n inserts: 0,\n value,\n }\n if (multiplicity < 0) {\n changes.deletes += Math.abs(multiplicity)\n } else if (multiplicity > 0) {\n changes.inserts += multiplicity\n changes.value = value\n }\n acc.set(key, changes)\n return acc\n }, new Map<unknown, { deletes: number; inserts: number; value: TResults }>())\n .forEach((changes, rawKey) => {\n const { deletes, inserts, value } = changes\n const valueWithKey = { ...value, _key: rawKey }\n if (inserts && !deletes) {\n write({\n value: valueWithKey,\n type: `insert`,\n })\n } else if (inserts >= deletes) {\n write({\n value: valueWithKey,\n type: `update`,\n })\n } else if (deletes > 0) {\n write({\n value: valueWithKey,\n type: `delete`,\n })\n }\n })\n commit()\n })\n )\n graph.finalize()\n }\n\n this.graph = graph\n this.inputs = inputs\n this.resultCollection = createCollection<TResults>({\n id: crypto.randomUUID(), // TODO: remove when we don't require any more\n getKey: (val: unknown) => {\n return (val as any)._key\n },\n sync: {\n sync,\n },\n })\n }\n\n get results() {\n return this.resultCollection\n }\n\n private sendChangesToInput(\n inputKey: string,\n changes: Array<ChangeMessage>,\n getKey: (item: ChangeMessage[`value`]) => any\n ) {\n const input = this.inputs[inputKey]!\n const multiSetArray: MultiSetArray<unknown> = []\n for (const change of changes) {\n const key = getKey(change.value)\n if (change.type === `insert`) {\n multiSetArray.push([[key, change.value], 1])\n } else if (change.type === `update`) {\n multiSetArray.push([[key, change.previousValue], -1])\n multiSetArray.push([[key, change.value], 1])\n } else {\n // change.type === `delete`\n multiSetArray.push([[key, change.value], -1])\n }\n }\n input.sendData(new MultiSet(multiSetArray))\n }\n\n private runGraph() {\n this.graph.run()\n }\n\n start() {\n if (this.state === `running`) {\n throw new Error(`Query is already running`)\n } else if (this.state === `stopped`) {\n throw new Error(`Query is stopped`)\n }\n\n // Send initial state\n Object.entries(this.inputCollections).forEach(([key, collection]) => {\n this.sendChangesToInput(\n key,\n collection.currentStateAsChanges(),\n collection.config.getKey\n )\n })\n this.runGraph()\n\n // Subscribe to changes\n Object.entries(this.inputCollections).forEach(([key, collection]) => {\n const unsubscribe = collection.subscribeChanges((changes) => {\n this.sendChangesToInput(key, changes, collection.config.getKey)\n this.runGraph()\n })\n\n this.unsubscribeCallbacks.push(unsubscribe)\n })\n\n this.state = `running`\n return () => {\n this.stop()\n }\n }\n\n stop() {\n this.unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe())\n this.unsubscribeCallbacks = []\n this.state = `stopped`\n }\n}\n"],"names":[],"mappings":";;;AAaO,SAAS,aACd,cACA;AACO,SAAA,IAAI,cAET,YAAY;AAChB;AAEO,MAAM,cAAiE;AAAA,EAQ5E,YAAY,cAA6C;AAHzD,SAAO,QAA4C;AACnD,SAAQ,uBAA0C,CAAC;AAGjD,UAAM,QAAQ,aAAa;AAC3B,UAAM,cAAc,MAAM;AAE1B,QAAI,CAAC,aAAa;AACV,YAAA,IAAI,MAAM,yBAAyB;AAAA,IAAA;AAG3C,SAAK,mBAAmB;AAElB,UAAA,QAAQ,IAAI,GAAG;AACrB,UAAM,SAAS,OAAO;AAAA,MACpB,OAAO,QAAQ,WAAW,EAAE,IAAI,CAAC,CAAC,GAAG,MAAM,CAAC,KAAK,MAAM,SAAA,CAAe,CAAC;AAAA,IACzE;AAEA,UAAM,OAAqC,CAAC,EAAE,OAAO,OAAO,aAAa;AACvE;AAAA,QACE;AAAA,QACA;AAAA,MAAA,EACA;AAAA,QACA,OAAO,CAAC,SAAS;AACT,gBAAA;AAEH,eAAA,WACA,OAAO,CAAC,KAAK,CAAC,CAAC,KAAK,KAAK,GAAG,YAAY,MAAM;AAC7C,kBAAM,UAAU,IAAI,IAAI,GAAG,KAAK;AAAA,cAC9B,SAAS;AAAA,cACT,SAAS;AAAA,cACT;AAAA,YACF;AACA,gBAAI,eAAe,GAAG;AACZ,sBAAA,WAAW,KAAK,IAAI,YAAY;AAAA,YAAA,WAC/B,eAAe,GAAG;AAC3B,sBAAQ,WAAW;AACnB,sBAAQ,QAAQ;AAAA,YAAA;AAEd,gBAAA,IAAI,KAAK,OAAO;AACb,mBAAA;AAAA,UAAA,uBACF,IAAoE,CAAC,EAC3E,QAAQ,CAAC,SAAS,WAAW;AAC5B,kBAAM,EAAE,SAAS,SAAS,MAAU,IAAA;AACpC,kBAAM,eAAe,EAAE,GAAG,OAAO,MAAM,OAAO;AAC1C,gBAAA,WAAW,CAAC,SAAS;AACjB,oBAAA;AAAA,gBACJ,OAAO;AAAA,gBACP,MAAM;AAAA,cAAA,CACP;AAAA,YAAA,WACQ,WAAW,SAAS;AACvB,oBAAA;AAAA,gBACJ,OAAO;AAAA,gBACP,MAAM;AAAA,cAAA,CACP;AAAA,YAAA,WACQ,UAAU,GAAG;AAChB,oBAAA;AAAA,gBACJ,OAAO;AAAA,gBACP,MAAM;AAAA,cAAA,CACP;AAAA,YAAA;AAAA,UACH,CACD;AACI,iBAAA;AAAA,QACR,CAAA;AAAA,MACH;AACA,YAAM,SAAS;AAAA,IACjB;AAEA,SAAK,QAAQ;AACb,SAAK,SAAS;AACd,SAAK,mBAAmB,iBAA2B;AAAA,MACjD,IAAI,OAAO,WAAW;AAAA;AAAA,MACtB,QAAQ,CAAC,QAAiB;AACxB,eAAQ,IAAY;AAAA,MACtB;AAAA,MACA,MAAM;AAAA,QACJ;AAAA,MAAA;AAAA,IACF,CACD;AAAA,EAAA;AAAA,EAGH,IAAI,UAAU;AACZ,WAAO,KAAK;AAAA,EAAA;AAAA,EAGN,mBACN,UACA,SACA,QACA;AACM,UAAA,QAAQ,KAAK,OAAO,QAAQ;AAClC,UAAM,gBAAwC,CAAC;AAC/C,eAAW,UAAU,SAAS;AACtB,YAAA,MAAM,OAAO,OAAO,KAAK;AAC3B,UAAA,OAAO,SAAS,UAAU;AACd,sBAAA,KAAK,CAAC,CAAC,KAAK,OAAO,KAAK,GAAG,CAAC,CAAC;AAAA,MAC7C,WAAW,OAAO,SAAS,UAAU;AACrB,sBAAA,KAAK,CAAC,CAAC,KAAK,OAAO,aAAa,GAAG,EAAE,CAAC;AACtC,sBAAA,KAAK,CAAC,CAAC,KAAK,OAAO,KAAK,GAAG,CAAC,CAAC;AAAA,MAAA,OACtC;AAES,sBAAA,KAAK,CAAC,CAAC,KAAK,OAAO,KAAK,GAAG,EAAE,CAAC;AAAA,MAAA;AAAA,IAC9C;AAEF,UAAM,SAAS,IAAI,SAAS,aAAa,CAAC;AAAA,EAAA;AAAA,EAGpC,WAAW;AACjB,SAAK,MAAM,IAAI;AAAA,EAAA;AAAA,EAGjB,QAAQ;AACF,QAAA,KAAK,UAAU,WAAW;AACtB,YAAA,IAAI,MAAM,0BAA0B;AAAA,IAC5C,WAAW,KAAK,UAAU,WAAW;AAC7B,YAAA,IAAI,MAAM,kBAAkB;AAAA,IAAA;AAI7B,WAAA,QAAQ,KAAK,gBAAgB,EAAE,QAAQ,CAAC,CAAC,KAAK,UAAU,MAAM;AAC9D,WAAA;AAAA,QACH;AAAA,QACA,WAAW,sBAAsB;AAAA,QACjC,WAAW,OAAO;AAAA,MACpB;AAAA,IAAA,CACD;AACD,SAAK,SAAS;AAGP,WAAA,QAAQ,KAAK,gBAAgB,EAAE,QAAQ,CAAC,CAAC,KAAK,UAAU,MAAM;AACnE,YAAM,cAAc,WAAW,iBAAiB,CAAC,YAAY;AAC3D,aAAK,mBAAmB,KAAK,SAAS,WAAW,OAAO,MAAM;AAC9D,aAAK,SAAS;AAAA,MAAA,CACf;AAEI,WAAA,qBAAqB,KAAK,WAAW;AAAA,IAAA,CAC3C;AAED,SAAK,QAAQ;AACb,WAAO,MAAM;AACX,WAAK,KAAK;AAAA,IACZ;AAAA,EAAA;AAAA,EAGF,OAAO;AACL,SAAK,qBAAqB,QAAQ,CAAC,gBAAgB,aAAa;AAChE,SAAK,uBAAuB,CAAC;AAC7B,SAAK,QAAQ;AAAA,EAAA;AAEjB;"}
1
+ {"version":3,"file":"compiled-query.js","sources":["../../../src/query/compiled-query.ts"],"sourcesContent":["import { D2, MultiSet, output } from \"@electric-sql/d2mini\"\nimport { createCollection } from \"../collection.js\"\nimport { compileQueryPipeline } from \"./pipeline-compiler.js\"\nimport type { Collection } from \"../collection.js\"\nimport type { ChangeMessage, ResolveType, SyncConfig } from \"../types.js\"\nimport type {\n IStreamBuilder,\n MultiSetArray,\n RootStreamBuilder,\n} from \"@electric-sql/d2mini\"\nimport type { QueryBuilder, ResultsFromContext } from \"./query-builder.js\"\nimport type { Context, Schema } from \"./types.js\"\n\nexport function compileQuery<TContext extends Context<Schema>>(\n queryBuilder: QueryBuilder<TContext>\n) {\n return new CompiledQuery<\n ResultsFromContext<TContext> & { _key?: string | number }\n >(queryBuilder)\n}\n\nexport class CompiledQuery<TResults extends object = Record<string, unknown>> {\n private graph: D2\n private inputs: Record<string, RootStreamBuilder<any>>\n private inputCollections: Record<string, Collection<any>>\n private resultCollection: Collection<TResults>\n public state: `compiled` | `running` | `stopped` = `compiled`\n private unsubscribeCallbacks: Array<() => void> = []\n\n constructor(queryBuilder: QueryBuilder<Context<Schema>>) {\n const query = queryBuilder._query\n const collections = query.collections\n\n if (!collections) {\n throw new Error(`No collections provided`)\n }\n\n this.inputCollections = collections\n\n const graph = new D2()\n const inputs = Object.fromEntries(\n Object.entries(collections).map(([key]) => [key, graph.newInput<any>()])\n )\n\n // Use TResults directly to ensure type compatibility\n const sync: SyncConfig<TResults>[`sync`] = ({\n begin,\n write,\n commit,\n collection,\n }) => {\n compileQueryPipeline<IStreamBuilder<[unknown, TResults]>>(\n query,\n inputs\n ).pipe(\n output((data) => {\n begin()\n data\n .getInner()\n .reduce((acc, [[key, value], multiplicity]) => {\n const changes = acc.get(key) || {\n deletes: 0,\n inserts: 0,\n value,\n }\n if (multiplicity < 0) {\n changes.deletes += Math.abs(multiplicity)\n } else if (multiplicity > 0) {\n changes.inserts += multiplicity\n changes.value = value\n }\n acc.set(key, changes)\n return acc\n }, new Map<unknown, { deletes: number; inserts: number; value: TResults }>())\n .forEach((changes, rawKey) => {\n const { deletes, inserts, value } = changes\n const valueWithKey = { ...value, _key: rawKey }\n\n // Simple singular insert.\n if (inserts && deletes === 0) {\n write({\n value: valueWithKey,\n type: `insert`,\n })\n } else if (\n // Insert & update(s) (updates are a delete & insert)\n inserts > deletes ||\n // Just update(s) but the item is already in the collection (so\n // was inserted previously).\n (inserts === deletes &&\n collection.has(valueWithKey._key as string | number))\n ) {\n write({\n value: valueWithKey,\n type: `update`,\n })\n // Only delete is left as an option\n } else if (deletes > 0) {\n write({\n value: valueWithKey,\n type: `delete`,\n })\n } else {\n throw new Error(\n `This should never happen ${JSON.stringify(changes)}`\n )\n }\n })\n commit()\n })\n )\n graph.finalize()\n }\n\n this.graph = graph\n this.inputs = inputs\n this.resultCollection = createCollection<TResults>({\n getKey: (val: unknown) => {\n return (val as any)._key\n },\n sync: {\n sync: sync as unknown as (params: {\n collection: Collection<\n ResolveType<TResults, never, Record<string, unknown>>,\n string | number,\n {}\n >\n begin: () => void\n write: (\n message: Omit<\n ChangeMessage<\n ResolveType<TResults, never, Record<string, unknown>>,\n string | number\n >,\n `key`\n >\n ) => void\n commit: () => void\n }) => void,\n },\n }) as unknown as Collection<TResults, string | number, {}>\n }\n\n get results() {\n return this.resultCollection\n }\n\n private sendChangesToInput(\n inputKey: string,\n changes: Array<ChangeMessage>,\n getKey: (item: ChangeMessage[`value`]) => any\n ) {\n const input = this.inputs[inputKey]!\n const multiSetArray: MultiSetArray<unknown> = []\n for (const change of changes) {\n const key = getKey(change.value)\n if (change.type === `insert`) {\n multiSetArray.push([[key, change.value], 1])\n } else if (change.type === `update`) {\n multiSetArray.push([[key, change.previousValue], -1])\n multiSetArray.push([[key, change.value], 1])\n } else {\n // change.type === `delete`\n multiSetArray.push([[key, change.value], -1])\n }\n }\n input.sendData(new MultiSet(multiSetArray))\n }\n\n private runGraph() {\n this.graph.run()\n }\n\n start() {\n if (this.state === `running`) {\n throw new Error(`Query is already running`)\n } else if (this.state === `stopped`) {\n throw new Error(`Query is stopped`)\n }\n\n // Send initial state\n Object.entries(this.inputCollections).forEach(([key, collection]) => {\n this.sendChangesToInput(\n key,\n collection.currentStateAsChanges(),\n collection.config.getKey\n )\n })\n this.runGraph()\n\n // Subscribe to changes\n Object.entries(this.inputCollections).forEach(([key, collection]) => {\n const unsubscribe = collection.subscribeChanges((changes) => {\n this.sendChangesToInput(key, changes, collection.config.getKey)\n this.runGraph()\n })\n\n this.unsubscribeCallbacks.push(unsubscribe)\n })\n\n this.state = `running`\n return () => {\n this.stop()\n }\n }\n\n stop() {\n this.unsubscribeCallbacks.forEach((unsubscribe) => unsubscribe())\n this.unsubscribeCallbacks = []\n this.state = `stopped`\n }\n}\n"],"names":[],"mappings":"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"}
@@ -2,6 +2,23 @@ import { IStreamBuilder } from '@electric-sql/d2mini';
2
2
  import { Collection } from './collection.js';
3
3
  import { StandardSchemaV1 } from '@standard-schema/spec';
4
4
  import { Transaction } from './transactions.js';
5
+ /**
6
+ * Helper type to extract the output type from a standard schema
7
+ *
8
+ * @internal This is used by the type resolution system
9
+ */
10
+ export type InferSchemaOutput<T> = T extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<T> extends object ? StandardSchemaV1.InferOutput<T> : Record<string, unknown> : Record<string, unknown>;
11
+ /**
12
+ * Helper type to determine the final type based on priority:
13
+ * 1. Explicit generic TExplicit (if not 'unknown')
14
+ * 2. Schema output type (if schema provided)
15
+ * 3. Fallback type TFallback
16
+ *
17
+ * @remarks
18
+ * This type is used internally to resolve the collection item type based on the provided generics and schema.
19
+ * Users should not need to use this type directly, but understanding the priority order helps when defining collections.
20
+ */
21
+ export type ResolveType<TExplicit, TSchema extends StandardSchemaV1 = never, TFallback extends object = Record<string, unknown>> = unknown extends TExplicit ? [TSchema] extends [never] ? TFallback : InferSchemaOutput<TSchema> : TExplicit extends object ? TExplicit : Record<string, unknown>;
5
22
  export type TransactionState = `pending` | `persisting` | `completed` | `failed`;
6
23
  /**
7
24
  * Represents a utility function that can be attached to a collection
@@ -15,11 +32,11 @@ export type UtilsRecord = Record<string, Fn>;
15
32
  * Represents a pending mutation within a transaction
16
33
  * Contains information about the original and modified data, as well as metadata
17
34
  */
18
- export interface PendingMutation<T extends object = Record<string, unknown>> {
35
+ export interface PendingMutation<T extends object = Record<string, unknown>, TOperation extends OperationType = OperationType> {
19
36
  mutationId: string;
20
- original: Partial<T>;
37
+ original: TOperation extends `insert` ? {} : T;
21
38
  modified: T;
22
- changes: Partial<T>;
39
+ changes: TOperation extends `insert` ? T : TOperation extends `delete` ? T : Partial<T>;
23
40
  globalKey: string;
24
41
  key: any;
25
42
  type: OperationType;
@@ -44,8 +61,8 @@ export type NonEmptyArray<T> = [T, ...Array<T>];
44
61
  * Utility type for a Transaction with at least one mutation
45
62
  * This is used internally by the Transaction.commit method
46
63
  */
47
- export type TransactionWithMutations<T extends object = Record<string, unknown>> = Transaction<T> & {
48
- mutations: NonEmptyArray<PendingMutation<T>>;
64
+ export type TransactionWithMutations<T extends object = Record<string, unknown>, TOperation extends OperationType = OperationType> = Transaction<T> & {
65
+ mutations: NonEmptyArray<PendingMutation<T, TOperation>>;
49
66
  };
50
67
  export interface TransactionConfig<T extends object = Record<string, unknown>> {
51
68
  /** Unique identifier for the transaction */
@@ -106,10 +123,22 @@ export interface OperationConfig {
106
123
  export interface InsertConfig {
107
124
  metadata?: Record<string, unknown>;
108
125
  }
109
- export interface CollectionConfig<T extends object = Record<string, unknown>, TKey extends string | number = string | number> {
126
+ export type UpdateMutationFnParams<T extends object = Record<string, unknown>> = {
127
+ transaction: TransactionWithMutations<T, `update`>;
128
+ };
129
+ export type InsertMutationFnParams<T extends object = Record<string, unknown>> = {
130
+ transaction: TransactionWithMutations<T, `insert`>;
131
+ };
132
+ export type DeleteMutationFnParams<T extends object = Record<string, unknown>> = {
133
+ transaction: TransactionWithMutations<T, `delete`>;
134
+ };
135
+ export type InsertMutationFn<T extends object = Record<string, unknown>> = (params: InsertMutationFnParams<T>) => Promise<any>;
136
+ export type UpdateMutationFn<T extends object = Record<string, unknown>> = (params: UpdateMutationFnParams<T>) => Promise<any>;
137
+ export type DeleteMutationFn<T extends object = Record<string, unknown>> = (params: DeleteMutationFnParams<T>) => Promise<any>;
138
+ export interface CollectionConfig<T extends object = Record<string, unknown>, TKey extends string | number = string | number, TSchema extends StandardSchemaV1 = StandardSchemaV1> {
110
139
  id?: string;
111
140
  sync: SyncConfig<T, TKey>;
112
- schema?: StandardSchema<T>;
141
+ schema?: TSchema;
113
142
  /**
114
143
  * Function to extract the ID from an object
115
144
  * This is required for update/delete operations which now only accept IDs
@@ -125,19 +154,19 @@ export interface CollectionConfig<T extends object = Record<string, unknown>, TK
125
154
  * @param params Object containing transaction and mutation information
126
155
  * @returns Promise resolving to any value
127
156
  */
128
- onInsert?: MutationFn<T>;
157
+ onInsert?: InsertMutationFn<T>;
129
158
  /**
130
159
  * Optional asynchronous handler function called before an update operation
131
160
  * @param params Object containing transaction and mutation information
132
161
  * @returns Promise resolving to any value
133
162
  */
134
- onUpdate?: MutationFn<T>;
163
+ onUpdate?: UpdateMutationFn<T>;
135
164
  /**
136
165
  * Optional asynchronous handler function called before a delete operation
137
166
  * @param params Object containing transaction and mutation information
138
167
  * @returns Promise resolving to any value
139
168
  */
140
- onDelete?: MutationFn<T>;
169
+ onDelete?: DeleteMutationFn<T>;
141
170
  }
142
171
  export type ChangesPayload<T extends object = Record<string, unknown>> = Array<ChangeMessage<T>>;
143
172
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tanstack/db",
3
3
  "description": "A reactive client store for building super fast apps on sync",
4
- "version": "0.0.11",
4
+ "version": "0.0.12",
5
5
  "dependencies": {
6
6
  "@electric-sql/d2mini": "^0.1.2",
7
7
  "@standard-schema/spec": "^1.0.0",
package/src/collection.ts CHANGED
@@ -11,23 +11,16 @@ import type {
11
11
  OperationConfig,
12
12
  OptimisticChangeMessage,
13
13
  PendingMutation,
14
+ ResolveType,
14
15
  StandardSchema,
15
16
  Transaction as TransactionType,
16
17
  UtilsRecord,
17
18
  } from "./types"
19
+ import type { StandardSchemaV1 } from "@standard-schema/spec"
18
20
 
19
21
  // Store collections in memory
20
22
  export const collectionsStore = new Map<string, CollectionImpl<any, any>>()
21
23
 
22
- // Map to track loading collections
23
- const loadingCollectionResolvers = new Map<
24
- string,
25
- {
26
- promise: Promise<CollectionImpl<any, any>>
27
- resolve: (value: CollectionImpl<any, any>) => void
28
- }
29
- >()
30
-
31
24
  interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
32
25
  committed: boolean
33
26
  operations: Array<OptimisticChangeMessage<T>>
@@ -36,6 +29,7 @@ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
36
29
  /**
37
30
  * Enhanced Collection interface that includes both data type T and utilities TUtils
38
31
  * @template T - The type of items in the collection
32
+ * @template TKey - The type of the key for the collection
39
33
  * @template TUtils - The utilities record type
40
34
  */
41
35
  export interface Collection<
@@ -49,20 +43,53 @@ export interface Collection<
49
43
  /**
50
44
  * Creates a new Collection instance with the given configuration
51
45
  *
52
- * @template T - The type of items in the collection
46
+ * @template TExplicit - The explicit type of items in the collection (highest priority)
53
47
  * @template TKey - The type of the key for the collection
54
48
  * @template TUtils - The utilities record type
49
+ * @template TSchema - The schema type for validation and type inference (second priority)
50
+ * @template TFallback - The fallback type if no explicit or schema type is provided
55
51
  * @param options - Collection options with optional utilities
56
52
  * @returns A new Collection with utilities exposed both at top level and under .utils
53
+ *
54
+ * @example
55
+ * // Using explicit type
56
+ * const todos = createCollection<Todo>({
57
+ * getKey: (todo) => todo.id,
58
+ * sync: { sync: () => {} }
59
+ * })
60
+ *
61
+ * // Using schema for type inference (preferred as it also gives you client side validation)
62
+ * const todoSchema = z.object({
63
+ * id: z.string(),
64
+ * title: z.string(),
65
+ * completed: z.boolean()
66
+ * })
67
+ *
68
+ * const todos = createCollection({
69
+ * schema: todoSchema,
70
+ * getKey: (todo) => todo.id,
71
+ * sync: { sync: () => {} }
72
+ * })
73
+ *
74
+ * // Note: You must provide either an explicit type or a schema, but not both
57
75
  */
58
76
  export function createCollection<
59
- T extends object = Record<string, unknown>,
77
+ TExplicit = unknown,
60
78
  TKey extends string | number = string | number,
61
79
  TUtils extends UtilsRecord = {},
80
+ TSchema extends StandardSchemaV1 = StandardSchemaV1,
81
+ TFallback extends object = Record<string, unknown>,
62
82
  >(
63
- options: CollectionConfig<T, TKey> & { utils?: TUtils }
64
- ): Collection<T, TKey, TUtils> {
65
- const collection = new CollectionImpl<T, TKey>(options)
83
+ options: CollectionConfig<
84
+ ResolveType<TExplicit, TSchema, TFallback>,
85
+ TKey,
86
+ TSchema
87
+ > & { utils?: TUtils }
88
+ ): Collection<ResolveType<TExplicit, TSchema, TFallback>, TKey, TUtils> {
89
+ const collection = new CollectionImpl<
90
+ ResolveType<TExplicit, TSchema, TFallback>,
91
+ TKey
92
+ >(options)
66
93
 
67
94
  // Copy utils to both top level and .utils namespace
68
95
  if (options.utils) {
@@ -71,98 +98,11 @@ export function createCollection<
71
98
  collection.utils = {} as TUtils
72
99
  }
73
100
 
74
- return collection as Collection<T, TKey, TUtils>
75
- }
76
-
77
- /**
78
- * Preloads a collection with the given configuration
79
- * Returns a promise that resolves once the sync tool has done its first commit (initial sync is finished)
80
- * If the collection has already loaded, it resolves immediately
81
- *
82
- * This function is useful in route loaders or similar pre-rendering scenarios where you want
83
- * to ensure data is available before a route transition completes. It uses the same shared collection
84
- * instance that will be used by useCollection, ensuring data consistency.
85
- *
86
- * @example
87
- * ```typescript
88
- * // In a route loader
89
- * async function loader({ params }) {
90
- * await preloadCollection({
91
- * id: `users-${params.userId}`,
92
- * sync: { ... },
93
- * });
94
- *
95
- * return null;
96
- * }
97
- * ```
98
- *
99
- * @template T - The type of items in the collection
100
- * @param config - Configuration for the collection, including id and sync
101
- * @returns Promise that resolves when the initial sync is finished
102
- */
103
- export function preloadCollection<
104
- T extends object = Record<string, unknown>,
105
- TKey extends string | number = string | number,
106
- >(config: CollectionConfig<T, TKey>): Promise<CollectionImpl<T, TKey>> {
107
- if (!config.id) {
108
- throw new Error(`The id property is required for preloadCollection`)
109
- }
110
-
111
- // If the collection is already fully loaded, return a resolved promise
112
- if (
113
- collectionsStore.has(config.id) &&
114
- !loadingCollectionResolvers.has(config.id)
115
- ) {
116
- return Promise.resolve(
117
- collectionsStore.get(config.id)! as CollectionImpl<T, TKey>
118
- )
119
- }
120
-
121
- // If the collection is in the process of loading, return its promise
122
- if (loadingCollectionResolvers.has(config.id)) {
123
- return loadingCollectionResolvers.get(config.id)!.promise
124
- }
125
-
126
- // Create a new collection instance if it doesn't exist
127
- if (!collectionsStore.has(config.id)) {
128
- collectionsStore.set(
129
- config.id,
130
- createCollection<T, TKey>({
131
- id: config.id,
132
- getKey: config.getKey,
133
- sync: config.sync,
134
- schema: config.schema,
135
- })
136
- )
137
- }
138
-
139
- const collection = collectionsStore.get(config.id)! as CollectionImpl<T, TKey>
140
-
141
- // Create a promise that will resolve after the first commit
142
- let resolveFirstCommit: (value: CollectionImpl<T, TKey>) => void
143
- const firstCommitPromise = new Promise<CollectionImpl<T, TKey>>((resolve) => {
144
- resolveFirstCommit = resolve
145
- })
146
-
147
- // Store the loading promise first
148
- loadingCollectionResolvers.set(config.id, {
149
- promise: firstCommitPromise,
150
- resolve: resolveFirstCommit!,
151
- })
152
-
153
- // Register a one-time listener for the first commit
154
- collection.onFirstCommit(() => {
155
- if (!config.id) {
156
- throw new Error(`The id property is required for preloadCollection`)
157
- }
158
- if (loadingCollectionResolvers.has(config.id)) {
159
- const resolver = loadingCollectionResolvers.get(config.id)!
160
- loadingCollectionResolvers.delete(config.id)
161
- resolver.resolve(collection)
162
- }
163
- })
164
-
165
- return firstCommitPromise
101
+ return collection as Collection<
102
+ ResolveType<TExplicit, TSchema, TFallback>,
103
+ TKey,
104
+ TUtils
105
+ >
166
106
  }
167
107
 
168
108
  /**
@@ -184,8 +124,8 @@ export class SchemaValidationError extends Error {
184
124
  message?: string
185
125
  ) {
186
126
  const defaultMessage = `${type === `insert` ? `Insert` : `Update`} validation failed: ${issues
187
- .map((issue) => issue.message)
188
- .join(`, `)}`
127
+ .map((issue) => `\n- ${issue.message} - path: ${issue.path}`)
128
+ .join(``)}`
189
129
 
190
130
  super(message || defaultMessage)
191
131
  this.name = `SchemaValidationError`
@@ -221,7 +161,7 @@ export class CollectionImpl<
221
161
 
222
162
  private pendingSyncedTransactions: Array<PendingSyncedTransaction<T>> = []
223
163
  private syncedKeys = new Set<TKey>()
224
- public config: CollectionConfig<T, TKey>
164
+ public config: CollectionConfig<T, TKey, any>
225
165
  private hasReceivedFirstCommit = false
226
166
 
227
167
  // Array to store one-time commit listeners
@@ -244,7 +184,7 @@ export class CollectionImpl<
244
184
  * @param config - Configuration object for the collection
245
185
  * @throws Error if sync config is missing
246
186
  */
247
- constructor(config: CollectionConfig<T, TKey>) {
187
+ constructor(config: CollectionConfig<T, TKey, any>) {
248
188
  // eslint-disable-next-line
249
189
  if (!config) {
250
190
  throw new Error(`Collection requires a config`)
@@ -803,7 +743,7 @@ export class CollectionImpl<
803
743
  }
804
744
 
805
745
  const items = Array.isArray(data) ? data : [data]
806
- const mutations: Array<PendingMutation<T>> = []
746
+ const mutations: Array<PendingMutation<T, `insert`>> = []
807
747
 
808
748
  // Create mutations for each item
809
749
  items.forEach((item) => {
@@ -817,7 +757,7 @@ export class CollectionImpl<
817
757
  }
818
758
  const globalKey = this.generateGlobalKey(key, item)
819
759
 
820
- const mutation: PendingMutation<T> = {
760
+ const mutation: PendingMutation<T, `insert`> = {
821
761
  mutationId: crypto.randomUUID(),
822
762
  original: {},
823
763
  modified: validatedData,
@@ -987,7 +927,7 @@ export class CollectionImpl<
987
927
  }
988
928
 
989
929
  // Create mutations for each object that has changes
990
- const mutations: Array<PendingMutation<T>> = keysArray
930
+ const mutations: Array<PendingMutation<T, `update`>> = keysArray
991
931
  .map((key, index) => {
992
932
  const itemChanges = changesArray[index] // User-provided changes for this specific item
993
933
 
@@ -1025,9 +965,9 @@ export class CollectionImpl<
1025
965
 
1026
966
  return {
1027
967
  mutationId: crypto.randomUUID(),
1028
- original: originalItem as Record<string, unknown>,
1029
- modified: modifiedItem as Record<string, unknown>,
1030
- changes: validatedUpdatePayload as Record<string, unknown>,
968
+ original: originalItem,
969
+ modified: modifiedItem,
970
+ changes: validatedUpdatePayload as Partial<T>,
1031
971
  globalKey,
1032
972
  key,
1033
973
  metadata: config.metadata as unknown,
@@ -1041,7 +981,7 @@ export class CollectionImpl<
1041
981
  collection: this,
1042
982
  }
1043
983
  })
1044
- .filter(Boolean) as Array<PendingMutation<T>>
984
+ .filter(Boolean) as Array<PendingMutation<T, `update`>>
1045
985
 
1046
986
  // If no changes were made, return an empty transaction early
1047
987
  if (mutations.length === 0) {
@@ -1117,15 +1057,20 @@ export class CollectionImpl<
1117
1057
  }
1118
1058
 
1119
1059
  const keysArray = Array.isArray(keys) ? keys : [keys]
1120
- const mutations: Array<PendingMutation<T>> = []
1060
+ const mutations: Array<PendingMutation<T, `delete`>> = []
1121
1061
 
1122
1062
  for (const key of keysArray) {
1063
+ if (!this.has(key)) {
1064
+ throw new Error(
1065
+ `Collection.delete was called with key '${key}' but there is no item in the collection with this key`
1066
+ )
1067
+ }
1123
1068
  const globalKey = this.generateGlobalKey(key, this.get(key)!)
1124
- const mutation: PendingMutation<T> = {
1069
+ const mutation: PendingMutation<T, `delete`> = {
1125
1070
  mutationId: crypto.randomUUID(),
1126
- original: this.get(key) || {},
1071
+ original: this.get(key)!,
1127
1072
  modified: this.get(key)!,
1128
- changes: this.get(key) || {},
1073
+ changes: this.get(key)!,
1129
1074
  globalKey,
1130
1075
  key,
1131
1076
  metadata: config?.metadata as unknown,
@@ -2,7 +2,7 @@ import { D2, MultiSet, output } from "@electric-sql/d2mini"
2
2
  import { createCollection } from "../collection.js"
3
3
  import { compileQueryPipeline } from "./pipeline-compiler.js"
4
4
  import type { Collection } from "../collection.js"
5
- import type { ChangeMessage, SyncConfig } from "../types.js"
5
+ import type { ChangeMessage, ResolveType, SyncConfig } from "../types.js"
6
6
  import type {
7
7
  IStreamBuilder,
8
8
  MultiSetArray,
@@ -42,7 +42,13 @@ export class CompiledQuery<TResults extends object = Record<string, unknown>> {
42
42
  Object.entries(collections).map(([key]) => [key, graph.newInput<any>()])
43
43
  )
44
44
 
45
- const sync: SyncConfig<TResults>[`sync`] = ({ begin, write, commit }) => {
45
+ // Use TResults directly to ensure type compatibility
46
+ const sync: SyncConfig<TResults>[`sync`] = ({
47
+ begin,
48
+ write,
49
+ commit,
50
+ collection,
51
+ }) => {
46
52
  compileQueryPipeline<IStreamBuilder<[unknown, TResults]>>(
47
53
  query,
48
54
  inputs
@@ -69,21 +75,35 @@ export class CompiledQuery<TResults extends object = Record<string, unknown>> {
69
75
  .forEach((changes, rawKey) => {
70
76
  const { deletes, inserts, value } = changes
71
77
  const valueWithKey = { ...value, _key: rawKey }
72
- if (inserts && !deletes) {
78
+
79
+ // Simple singular insert.
80
+ if (inserts && deletes === 0) {
73
81
  write({
74
82
  value: valueWithKey,
75
83
  type: `insert`,
76
84
  })
77
- } else if (inserts >= deletes) {
85
+ } else if (
86
+ // Insert & update(s) (updates are a delete & insert)
87
+ inserts > deletes ||
88
+ // Just update(s) but the item is already in the collection (so
89
+ // was inserted previously).
90
+ (inserts === deletes &&
91
+ collection.has(valueWithKey._key as string | number))
92
+ ) {
78
93
  write({
79
94
  value: valueWithKey,
80
95
  type: `update`,
81
96
  })
97
+ // Only delete is left as an option
82
98
  } else if (deletes > 0) {
83
99
  write({
84
100
  value: valueWithKey,
85
101
  type: `delete`,
86
102
  })
103
+ } else {
104
+ throw new Error(
105
+ `This should never happen ${JSON.stringify(changes)}`
106
+ )
87
107
  }
88
108
  })
89
109
  commit()
@@ -95,14 +115,30 @@ export class CompiledQuery<TResults extends object = Record<string, unknown>> {
95
115
  this.graph = graph
96
116
  this.inputs = inputs
97
117
  this.resultCollection = createCollection<TResults>({
98
- id: crypto.randomUUID(), // TODO: remove when we don't require any more
99
118
  getKey: (val: unknown) => {
100
119
  return (val as any)._key
101
120
  },
102
121
  sync: {
103
- sync,
122
+ sync: sync as unknown as (params: {
123
+ collection: Collection<
124
+ ResolveType<TResults, never, Record<string, unknown>>,
125
+ string | number,
126
+ {}
127
+ >
128
+ begin: () => void
129
+ write: (
130
+ message: Omit<
131
+ ChangeMessage<
132
+ ResolveType<TResults, never, Record<string, unknown>>,
133
+ string | number
134
+ >,
135
+ `key`
136
+ >
137
+ ) => void
138
+ commit: () => void
139
+ }) => void,
104
140
  },
105
- })
141
+ }) as unknown as Collection<TResults, string | number, {}>
106
142
  }
107
143
 
108
144
  get results() {