@peers-app/peers-sdk 0.26.6 → 0.27.1

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.
Files changed (189) hide show
  1. package/README.md +3 -1
  2. package/bin/peers-package.js +13 -0
  3. package/define/package.json +4 -0
  4. package/dist/contracts/__tests__/action-dispatch.test.js +56 -76
  5. package/dist/contracts/__tests__/also-implements.test.d.ts +1 -0
  6. package/dist/contracts/__tests__/also-implements.test.js +110 -0
  7. package/dist/contracts/__tests__/builder.test.js +45 -0
  8. package/dist/contracts/__tests__/contract-resolvers.test.js +134 -4
  9. package/dist/contracts/__tests__/frozen-contracts.test.d.ts +1 -0
  10. package/dist/contracts/__tests__/frozen-contracts.test.js +191 -0
  11. package/dist/contracts/__tests__/generic-tool-dispatch.test.js +13 -56
  12. package/dist/contracts/__tests__/host-contract-client.test.d.ts +1 -0
  13. package/dist/contracts/__tests__/host-contract-client.test.js +793 -0
  14. package/dist/contracts/__tests__/host-contract-transport.test.js +15 -0
  15. package/dist/contracts/__tests__/registry.test.js +68 -0
  16. package/dist/contracts/__tests__/target-dispatch.d.ts +11 -0
  17. package/dist/contracts/__tests__/target-dispatch.js +24 -0
  18. package/dist/contracts/__tests__/tool-catalog-service.test.js +37 -1
  19. package/dist/contracts/__tests__/tool-catalog.test.js +12 -3
  20. package/dist/contracts/__tests__/validate.test.js +177 -7
  21. package/dist/contracts/__tests__/workflow-context.test.js +0 -11
  22. package/dist/contracts/builder.d.ts +16 -2
  23. package/dist/contracts/builder.js +44 -3
  24. package/dist/contracts/contract-definition-schema.d.ts +251 -0
  25. package/dist/contracts/contract-definition-schema.js +48 -0
  26. package/dist/contracts/contract-provider-router.d.ts +2 -0
  27. package/dist/contracts/contract-provider-router.js +5 -2
  28. package/dist/contracts/contract-proxy.d.ts +52 -18
  29. package/dist/contracts/contract-proxy.js +144 -203
  30. package/dist/contracts/contract-resolvers.d.ts +6 -4
  31. package/dist/contracts/contract-resolvers.js +32 -9
  32. package/dist/contracts/frozen-contracts.d.ts +59 -0
  33. package/dist/contracts/frozen-contracts.js +86 -0
  34. package/dist/contracts/host-contract-client.d.ts +134 -0
  35. package/dist/contracts/host-contract-client.js +396 -0
  36. package/dist/contracts/host-contract-transport.d.ts +2 -0
  37. package/dist/contracts/host-contract-transport.js +8 -0
  38. package/dist/contracts/index.d.ts +6 -4
  39. package/dist/contracts/index.js +11 -5
  40. package/dist/contracts/registry.d.ts +31 -1
  41. package/dist/contracts/registry.js +100 -6
  42. package/dist/contracts/system/contract-catalog-contract.d.ts +61 -6
  43. package/dist/contracts/system/contract-catalog-contract.js +17 -5
  44. package/dist/contracts/tool-catalog-service.js +26 -6
  45. package/dist/contracts/tool-catalog.d.ts +1 -1
  46. package/dist/contracts/tool-catalog.js +4 -1
  47. package/dist/contracts/types.d.ts +32 -2
  48. package/dist/contracts/validate.d.ts +16 -1
  49. package/dist/contracts/validate.js +139 -25
  50. package/dist/data/change-tracking.d.ts +17 -5
  51. package/dist/data/change-tracking.js +22 -6
  52. package/dist/data/change-tracking.test.d.ts +1 -0
  53. package/dist/data/change-tracking.test.js +62 -0
  54. package/dist/data/create-group.js +1 -1
  55. package/dist/data/group-members.d.ts +14 -0
  56. package/dist/data/group-members.js +36 -0
  57. package/dist/data/group-members.test.js +49 -0
  58. package/dist/data/orm/sql.data-source.d.ts +9 -0
  59. package/dist/data/orm/sql.data-source.js +47 -2
  60. package/dist/data/orm/sql.data-source.test.js +142 -0
  61. package/dist/data/orm/table-container.js +9 -9
  62. package/dist/data/orm/table-container.test.js +44 -0
  63. package/dist/data/orm/types.d.ts +41 -1
  64. package/dist/data/orm/types.js +159 -28
  65. package/dist/data/orm/types.test.js +129 -0
  66. package/dist/data/peer-types.d.ts +8 -62
  67. package/dist/data/persistent-vars.d.ts +4 -1
  68. package/dist/data/persistent-vars.js +7 -2
  69. package/dist/data/tools.d.ts +29 -221
  70. package/dist/define/also-implements.test.d.ts +1 -0
  71. package/dist/define/also-implements.test.js +123 -0
  72. package/dist/define/context.d.ts +77 -0
  73. package/dist/define/context.js +2 -0
  74. package/dist/define/contract-ref.d.ts +177 -0
  75. package/dist/define/contract-ref.js +67 -0
  76. package/dist/define/declare-contract.d.ts +167 -0
  77. package/dist/define/declare-contract.js +220 -0
  78. package/dist/define/declare-contract.test.d.ts +1 -0
  79. package/dist/define/declare-contract.test.js +573 -0
  80. package/dist/define/define-package.d.ts +203 -0
  81. package/dist/define/define-package.js +289 -0
  82. package/dist/define/define.test.d.ts +1 -0
  83. package/dist/define/define.test.js +1010 -0
  84. package/dist/define/empty-fields.d.ts +15 -0
  85. package/dist/define/empty-fields.js +134 -0
  86. package/dist/define/empty-fields.test.d.ts +1 -0
  87. package/dist/define/empty-fields.test.js +55 -0
  88. package/dist/define/events-observables.test.d.ts +1 -0
  89. package/dist/define/events-observables.test.js +1013 -0
  90. package/dist/define/events.d.ts +81 -0
  91. package/dist/define/events.js +81 -0
  92. package/dist/define/generated-field.d.ts +20 -0
  93. package/dist/define/generated-field.js +25 -0
  94. package/dist/define/index.d.ts +28 -0
  95. package/dist/define/index.js +62 -0
  96. package/dist/define/lazy-members.d.ts +8 -0
  97. package/dist/define/lazy-members.js +38 -0
  98. package/dist/define/manifest.d.ts +63 -0
  99. package/dist/define/manifest.js +653 -0
  100. package/dist/define/observables.d.ts +146 -0
  101. package/dist/define/observables.js +187 -0
  102. package/dist/define/serve-members.d.ts +21 -0
  103. package/dist/define/serve-members.js +152 -0
  104. package/dist/define/serve-members.test.d.ts +1 -0
  105. package/dist/define/serve-members.test.js +630 -0
  106. package/dist/define/serve.d.ts +22 -0
  107. package/dist/define/serve.js +58 -0
  108. package/dist/define/system-contracts.d.ts +41 -0
  109. package/dist/define/system-contracts.js +21 -0
  110. package/dist/define/table-handle.d.ts +13 -0
  111. package/dist/define/table-handle.js +36 -0
  112. package/dist/define/table.d.ts +128 -0
  113. package/dist/define/table.js +122 -0
  114. package/dist/define/tool.d.ts +129 -0
  115. package/dist/define/tool.js +91 -0
  116. package/dist/define/vars.d.ts +82 -0
  117. package/dist/define/vars.js +256 -0
  118. package/dist/define/vars.test.d.ts +1 -0
  119. package/dist/define/vars.test.js +39 -0
  120. package/dist/define/wire-value.d.ts +12 -0
  121. package/dist/define/wire-value.js +76 -0
  122. package/dist/define/worker-state.d.ts +15 -0
  123. package/dist/define/worker-state.js +45 -0
  124. package/dist/device/tx-encoding.test.js +10 -0
  125. package/dist/index.d.ts +3 -0
  126. package/dist/index.js +5 -1
  127. package/dist/invites/group-approval.js +4 -2
  128. package/dist/invites/group-approval.test.js +2 -0
  129. package/dist/observable.d.ts +42 -2
  130. package/dist/observable.js +25 -7
  131. package/dist/package-build/build-package.d.ts +60 -0
  132. package/dist/package-build/build-package.js +312 -0
  133. package/dist/package-build/build-package.test.d.ts +1 -0
  134. package/dist/package-build/build-package.test.js +204 -0
  135. package/dist/package-build/cli.d.ts +7 -0
  136. package/dist/package-build/cli.js +218 -0
  137. package/dist/package-build/contract-add.d.ts +35 -0
  138. package/dist/package-build/contract-add.js +97 -0
  139. package/dist/package-build/contract-add.test.d.ts +1 -0
  140. package/dist/package-build/contract-add.test.js +467 -0
  141. package/dist/package-build/contract-source.d.ts +59 -0
  142. package/dist/package-build/contract-source.js +471 -0
  143. package/dist/package-build/freeze-contracts.d.ts +56 -0
  144. package/dist/package-build/freeze-contracts.js +198 -0
  145. package/dist/package-build/freeze-contracts.test.d.ts +1 -0
  146. package/dist/package-build/freeze-contracts.test.js +355 -0
  147. package/dist/package-installer/package-creator.d.ts +4 -0
  148. package/dist/package-installer/package-creator.js +57 -16
  149. package/dist/package-installer/package-creator.test.js +86 -0
  150. package/dist/package-loader/contract-members.d.ts +53 -0
  151. package/dist/package-loader/contract-members.js +31 -0
  152. package/dist/package-loader/contract-package-loader.js +5 -1
  153. package/dist/package-loader/contract-package-loader.test.js +11 -0
  154. package/dist/package-loader/host-authority-loading.test.js +9 -5
  155. package/dist/package-loader/index.d.ts +1 -0
  156. package/dist/package-loader/index.js +1 -0
  157. package/dist/package-loader/isolated-artifact.d.ts +80 -17
  158. package/dist/package-loader/isolated-artifact.js +257 -51
  159. package/dist/package-loader/isolated-artifact.test.js +294 -21
  160. package/dist/package-loader/isolated-runtime.d.ts +22 -7
  161. package/dist/package-loader/isolated-table-gateway.js +30 -11
  162. package/dist/package-loader/isolated-table-gateway.test.js +243 -0
  163. package/dist/package-loader/package-loader.d.ts +8 -1
  164. package/dist/package-loader/package-loader.js +39 -20
  165. package/dist/package-loader/package-loader.test.js +47 -0
  166. package/dist/peer-id.d.ts +25 -0
  167. package/dist/peer-id.js +99 -0
  168. package/dist/serial-json.compat.test.d.ts +1 -0
  169. package/dist/serial-json.compat.test.js +369 -0
  170. package/dist/serial-json.d.ts +41 -5
  171. package/dist/serial-json.js +216 -157
  172. package/dist/serial-json.oracle.d.ts +21 -0
  173. package/dist/serial-json.oracle.js +222 -0
  174. package/dist/serial-json.test.js +315 -0
  175. package/dist/testing/index.d.ts +4 -0
  176. package/dist/testing/index.js +8 -0
  177. package/dist/testing/mock-host-contract.d.ts +123 -0
  178. package/dist/testing/mock-host-contract.js +434 -0
  179. package/dist/testing/mock-host-contract.test.d.ts +1 -0
  180. package/dist/testing/mock-host-contract.test.js +782 -0
  181. package/dist/types/field-type.d.ts +35 -18
  182. package/dist/types/field-type.js +4 -1
  183. package/dist/types/zod-types.d.ts +9 -0
  184. package/dist/types/zod-types.js +8 -2
  185. package/dist/utils.d.ts +22 -25
  186. package/dist/utils.js +45 -92
  187. package/dist/utils.test.js +36 -0
  188. package/package.json +9 -2
  189. package/testing/package.json +4 -0
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.validateProviderSatisfiesContract = validateProviderSatisfiesContract;
4
+ exports.contractShapeChanges = contractShapeChanges;
4
5
  exports.validateImmutability = validateImmutability;
5
6
  exports.validateAlsoImplements = validateAlsoImplements;
7
+ const lodash_1 = require("lodash");
6
8
  const types_1 = require("./types");
7
9
  function ok() {
8
10
  return { valid: true, errors: [] };
@@ -15,12 +17,13 @@ function merge(results) {
15
17
  return { valid: errors.length === 0, errors };
16
18
  }
17
19
  /**
18
- * Check whether `implField` satisfies the contract's `contractField`.
19
- * - Types must match exactly.
20
- * - If the contract field is required, the implementation must also be required.
21
- * - `isArray` must match.
20
+ * Check whether `implField` satisfies the contract's `contractField` for its callers.
21
+ * - Types must match exactly, and so must `isArray`.
22
+ * - What callers receive: a field the contract requires must be required.
23
+ * - What callers send: a field the contract makes optional must be optional, since callers may
24
+ * leave it out; the implementation may make a required one optional.
22
25
  */
23
- function isFieldCompatible(contractField, implField) {
26
+ function isFieldCompatible(contractField, implField, direction) {
24
27
  if (contractField.type !== implField.type) {
25
28
  return `type mismatch: contract requires '${contractField.type}', implementation has '${implField.type}'`;
26
29
  }
@@ -30,9 +33,12 @@ function isFieldCompatible(contractField, implField) {
30
33
  if (!contractField.isArray && implField.isArray) {
31
34
  return "contract requires scalar, implementation is an array";
32
35
  }
33
- if (!contractField.optional && implField.optional) {
36
+ if (direction !== "input" && !contractField.optional && implField.optional) {
34
37
  return "contract requires a required field, implementation marks it optional";
35
38
  }
39
+ if (direction !== "output" && contractField.optional && !implField.optional) {
40
+ return "contract marks the field optional, implementation requires it";
41
+ }
36
42
  return null;
37
43
  }
38
44
  function validateTableFields(contractTable, implTable) {
@@ -50,7 +56,7 @@ function validateTableFields(contractTable, implTable) {
50
56
  }
51
57
  continue;
52
58
  }
53
- const compat = isFieldCompatible(contractField, implField);
59
+ const compat = isFieldCompatible(contractField, implField, "both");
54
60
  if (compat) {
55
61
  errors.push({
56
62
  kind: "field",
@@ -89,7 +95,7 @@ function validateToolFields(contractTool, implTool, direction) {
89
95
  }
90
96
  continue;
91
97
  }
92
- const compat = isFieldCompatible(contractField, implField);
98
+ const compat = isFieldCompatible(contractField, implField, direction);
93
99
  if (compat) {
94
100
  errors.push({
95
101
  kind: "field",
@@ -129,7 +135,7 @@ function validateEventFields(contractEvent, implEvent) {
129
135
  }
130
136
  continue;
131
137
  }
132
- const compat = isFieldCompatible(contractField, implField);
138
+ const compat = isFieldCompatible(contractField, implField, "output");
133
139
  if (compat) {
134
140
  errors.push({
135
141
  kind: "field",
@@ -226,12 +232,132 @@ function validateProviderSatisfiesContract(impl, contract) {
226
232
  }
227
233
  return errors.length === 0 ? ok() : fail(errors);
228
234
  }
235
+ /**
236
+ * Keys the frozen comparison ignores: a stable version's descriptions may change, and so may a
237
+ * tool's policy (`remoteAccessLevel`, `requiresWorkflowRun`), which isn't part of its shape.
238
+ */
239
+ const IGNORED_KEYS = new Set([
240
+ "description",
241
+ "usageDescription",
242
+ "remoteAccessLevel",
243
+ "requiresWorkflowRun",
244
+ ]);
245
+ /**
246
+ * IR keys added after contracts were first frozen. A shape that has one matches a shape that
247
+ * doesn't (a provider built with an older SDK); where both have it, they must agree.
248
+ */
249
+ const LATER_IR_KEYS = new Set(["fields", "enumValues", "value"]);
250
+ function isRecord(value) {
251
+ return value !== null && typeof value === "object" && !Array.isArray(value);
252
+ }
253
+ function uniqueNames(items) {
254
+ const byName = new Map();
255
+ for (const item of items) {
256
+ if (!isRecord(item) || typeof item.name !== "string" || byName.has(item.name)) {
257
+ return undefined;
258
+ }
259
+ byName.set(item.name, item);
260
+ }
261
+ return byName;
262
+ }
263
+ /**
264
+ * Structural equality for the frozen comparison. Members and fields match by name in any order,
265
+ * `enumValues` as sets, and keys in any order; descriptions and tool policy are ignored, and so is
266
+ * a later IR key only one side has. A `defaultValue` must be equal as data.
267
+ */
268
+ function sameShape(a, b, key) {
269
+ if (key === "defaultValue") {
270
+ // Legacy schemaToFields retains Zod's default factories. Compare only their wire data,
271
+ // as the previous JSON comparison did, without executing or comparing factory identities.
272
+ return (0, lodash_1.isEqual)(JSON.parse(JSON.stringify({ value: a })), JSON.parse(JSON.stringify({ value: b })));
273
+ }
274
+ if (Array.isArray(a) && Array.isArray(b)) {
275
+ if (key === "enumValues") {
276
+ const values = new Set(b);
277
+ return new Set(a).size === values.size && a.every((value) => values.has(value));
278
+ }
279
+ if (a.length !== b.length) {
280
+ return false;
281
+ }
282
+ const aByName = uniqueNames(a);
283
+ const bByName = uniqueNames(b);
284
+ if (aByName && bByName) {
285
+ return [...aByName].every(([name, item]) => bByName.has(name) && sameShape(item, bByName.get(name)));
286
+ }
287
+ return a.every((item, index) => sameShape(item, b[index]));
288
+ }
289
+ if (isRecord(a) && isRecord(b)) {
290
+ for (const name of new Set([...Object.keys(a), ...Object.keys(b)])) {
291
+ const left = a[name];
292
+ const right = b[name];
293
+ if (IGNORED_KEYS.has(name) ||
294
+ (left === undefined && right === undefined) ||
295
+ (LATER_IR_KEYS.has(name) && (left === undefined || right === undefined))) {
296
+ continue;
297
+ }
298
+ if (!sameShape(left, right, name)) {
299
+ return false;
300
+ }
301
+ }
302
+ return true;
303
+ }
304
+ return a === b;
305
+ }
306
+ /** A contract's member lists, with what to call one member of each in a change. */
307
+ const MEMBER_KINDS = [
308
+ ["tables", "table"],
309
+ ["tools", "tool"],
310
+ ["observables", "observable"],
311
+ ["events", "event"],
312
+ ];
313
+ /**
314
+ * What changed between two shapes of one contract version, member by member: a table, tool,
315
+ * observable, or event that was added, removed, or changed. Empty when the shapes match.
316
+ * Descriptions and tool policy are ignored, as in {@link validateImmutability}.
317
+ *
318
+ * @param existing - The frozen shape.
319
+ * @param incoming - The shape to compare with it.
320
+ * @returns One line per changed member, such as `tool add-item changed`.
321
+ */
322
+ function contractShapeChanges(existing, incoming) {
323
+ const changes = [];
324
+ for (const [kind, label] of MEMBER_KINDS) {
325
+ const beforeMembers = existing[kind] ?? [];
326
+ const afterMembers = incoming[kind] ?? [];
327
+ // Do not collapse duplicate names: host-backed registrations can reach this comparison
328
+ // without isolated-artifact validation. Preserve the list comparison for such inputs.
329
+ if (!uniqueNames(beforeMembers) || !uniqueNames(afterMembers)) {
330
+ if (!sameShape(beforeMembers, afterMembers)) {
331
+ changes.push(`${label} list changed (duplicate member names)`);
332
+ }
333
+ continue;
334
+ }
335
+ const before = new Map(beforeMembers.map((member) => [member.name, member]));
336
+ const after = new Map(afterMembers.map((member) => [member.name, member]));
337
+ for (const [name, member] of before) {
338
+ if (!after.has(name))
339
+ changes.push(`${label} ${name} was removed`);
340
+ else if (!sameShape(member, after.get(name)))
341
+ changes.push(`${label} ${name} changed`);
342
+ }
343
+ for (const name of after.keys()) {
344
+ if (!before.has(name))
345
+ changes.push(`${label} ${name} was added`);
346
+ }
347
+ }
348
+ return changes;
349
+ }
229
350
  /**
230
351
  * Validate that a frozen (stable) contract definition has not been modified.
231
352
  * If the existing definition has no `devTag` it is frozen; the incoming
232
- * definition must be structurally identical and also stable.
353
+ * definition must have the same shape and also be stable.
233
354
  * Dev contracts (`devTag: "dev"`) may change freely but cannot be registered
234
355
  * once a stable version exists for that contract+version.
356
+ *
357
+ * Descriptions may change on a frozen version (to point callers at a newer one, say), and so
358
+ * may a tool's policy (`remoteAccessLevel`, `requiresWorkflowRun`); member, field, and key order
359
+ * don't matter. IR keys added later (`fields`, `enumValues`, and an
360
+ * observable's `value`) are compared only when both definitions have them.
235
361
  */
236
362
  function validateImmutability(existing, incoming) {
237
363
  if (existing.devTag === "dev") {
@@ -247,25 +373,13 @@ function validateImmutability(existing, incoming) {
247
373
  },
248
374
  ]);
249
375
  }
250
- // Deep-compare the shapes. We serialize to JSON for a simple structural comparison.
251
- const existingShape = JSON.stringify({
252
- tables: existing.tables,
253
- tools: existing.tools,
254
- observables: existing.observables,
255
- events: existing.events ?? [],
256
- });
257
- const incomingShape = JSON.stringify({
258
- tables: incoming.tables,
259
- tools: incoming.tools,
260
- observables: incoming.observables,
261
- events: incoming.events ?? [],
262
- });
263
- if (existingShape !== incomingShape) {
376
+ const changes = contractShapeChanges(existing, incoming);
377
+ if (changes.length > 0) {
264
378
  return fail([
265
379
  {
266
380
  kind: "contract",
267
381
  name: (0, types_1.contractKey)(existing.contractId, existing.version),
268
- message: `Stable contract ${existing.name} v${existing.version} is frozen and cannot be modified`,
382
+ message: `Stable contract ${existing.name} v${existing.version} is frozen and cannot be modified (${changes.join("; ")})`,
269
383
  },
270
384
  ]);
271
385
  }
@@ -59,7 +59,12 @@ export type IChangeRecord = z.infer<typeof changeRecordSchema>;
59
59
  * - `appliedClock` stamps `appliedAt` at insert time (per-device sync watermark)
60
60
  */
61
61
  export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
62
+ /**
63
+ * Writers fire this after storing change rows, whether written on this device, applied from a
64
+ * peer, or backfilled. Sync listens to advertise its latest `appliedAt`.
65
+ */
62
66
  readonly dataChangedEmitter: Emitter<IChangeRecord>;
67
+ /** Subscribe side of {@link dataChangedEmitter}. */
63
68
  readonly dataChanged: Event<IChangeRecord>;
64
69
  /**
65
70
  * Hybrid logical clock for author write times (`createdAt`, `supersededAt`).
@@ -112,15 +117,16 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
112
117
  */
113
118
  filterToDeletedIds(recordIds: string[]): Promise<string[]>;
114
119
  /**
115
- * Find recordIds that have no change records at all.
120
+ * Find recordIds that have no change records, in any table or only in `tableName`.
116
121
  *
117
122
  * This is useful for identifying records that were never synced
118
123
  * or have been completely cleaned up.
119
124
  *
120
125
  * @param recordIds - Array of recordIds to check
126
+ * @param tableName - Limit the lookup to this table; omitted checks across all tables
121
127
  * @returns Array of recordIds with no change records
122
128
  */
123
- filterToMissingIds(recordIds: string[]): Promise<string[]>;
129
+ filterToMissingIds(recordIds: string[], tableName?: string): Promise<string[]>;
124
130
  /**
125
131
  * Find changeIds that we do not have
126
132
  *
@@ -214,12 +220,18 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
214
220
  */
215
221
  deleteSupersededChangesOlderThan(tableName: string, beforeTimestamp: number): Promise<void>;
216
222
  /**
217
- * Bulk delete changes
223
+ * Bulk delete changes by ID.
218
224
  *
219
- * @param tableName - Name of the table to compact
220
- * @param beforeTimestamp - Delete changes superseded before this timestamp
225
+ * @param changeIds - IDs of the change rows to delete
221
226
  */
222
227
  deleteChanges(changeIds: string[]): Promise<void>;
228
+ /**
229
+ * Delete every change row for one table, active or superseded. Used when a table is deleted or
230
+ * becomes local-only: its rows stop syncing until a later schema enables sync again.
231
+ *
232
+ * @param tableName - Full name of the tracked table, as stored in each row's `tableName`
233
+ */
234
+ deleteTableChanges(tableName: string): Promise<void>;
223
235
  /**
224
236
  * Clear inline values on insert changes (op='set', path='/') for a specific table.
225
237
  * These values are no longer needed since they are resolved from the source table at query time.
@@ -63,7 +63,12 @@ const changeTrackingV2TableMetaData = {
63
63
  * - `appliedClock` stamps `appliedAt` at insert time (per-device sync watermark)
64
64
  */
65
65
  class ChangeTrackingTable extends orm_1.SQLDataSource {
66
+ /**
67
+ * Writers fire this after storing change rows, whether written on this device, applied from a
68
+ * peer, or backfilled. Sync listens to advertise its latest `appliedAt`.
69
+ */
66
70
  dataChangedEmitter;
71
+ /** Subscribe side of {@link dataChangedEmitter}. */
67
72
  dataChanged;
68
73
  /**
69
74
  * Hybrid logical clock for author write times (`createdAt`, `supersededAt`).
@@ -171,15 +176,16 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
171
176
  return deletedRecordsResult.map((d) => d.recordId);
172
177
  }
173
178
  /**
174
- * Find recordIds that have no change records at all.
179
+ * Find recordIds that have no change records, in any table or only in `tableName`.
175
180
  *
176
181
  * This is useful for identifying records that were never synced
177
182
  * or have been completely cleaned up.
178
183
  *
179
184
  * @param recordIds - Array of recordIds to check
185
+ * @param tableName - Limit the lookup to this table; omitted checks across all tables
180
186
  * @returns Array of recordIds with no change records
181
187
  */
182
- async filterToMissingIds(recordIds) {
188
+ async filterToMissingIds(recordIds, tableName) {
183
189
  if (recordIds.length === 0)
184
190
  return [];
185
191
  await this.initTable();
@@ -193,8 +199,9 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
193
199
  FROM InputRecordIds AS i
194
200
  LEFT JOIN "${this.tableName}" AS existing
195
201
  ON i.recordId = existing.recordId
202
+ ${tableName === undefined ? "" : "AND existing.tableName = ?"}
196
203
  WHERE existing.recordId IS NULL
197
- `, recordIds);
204
+ `, tableName === undefined ? recordIds : [...recordIds, tableName]);
198
205
  return missingRecordsResult.map((d) => d.recordId);
199
206
  }
200
207
  /**
@@ -443,10 +450,9 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
443
450
  `, [tableName, beforeTimestamp]);
444
451
  }
445
452
  /**
446
- * Bulk delete changes
453
+ * Bulk delete changes by ID.
447
454
  *
448
- * @param tableName - Name of the table to compact
449
- * @param beforeTimestamp - Delete changes superseded before this timestamp
455
+ * @param changeIds - IDs of the change rows to delete
450
456
  */
451
457
  async deleteChanges(changeIds) {
452
458
  await this.initTable();
@@ -455,6 +461,16 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
455
461
  WHERE changeId IN (${changeIds.map((_c) => "?").join()})
456
462
  `, changeIds);
457
463
  }
464
+ /**
465
+ * Delete every change row for one table, active or superseded. Used when a table is deleted or
466
+ * becomes local-only: its rows stop syncing until a later schema enables sync again.
467
+ *
468
+ * @param tableName - Full name of the tracked table, as stored in each row's `tableName`
469
+ */
470
+ async deleteTableChanges(tableName) {
471
+ await this.initTable();
472
+ await this.db.exec(`DELETE FROM "${this.tableName}" WHERE tableName = ?`, [tableName]);
473
+ }
458
474
  /**
459
475
  * Clear inline values on insert changes (op='set', path='/') for a specific table.
460
476
  * These values are no longer needed since they are resolved from the source table at query time.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const SQLiteDB = require("better-sqlite3");
4
+ const utils_1 = require("../utils");
5
+ const change_tracking_1 = require("./change-tracking");
6
+ describe("ChangeTrackingTable table-scoped history", () => {
7
+ let sqlite;
8
+ let changes;
9
+ beforeEach(() => {
10
+ sqlite = new SQLiteDB(":memory:");
11
+ const db = {
12
+ async get(sql, params = []) {
13
+ return sqlite.prepare(sql).get(params);
14
+ },
15
+ async all(sql, params = []) {
16
+ return sqlite.prepare(sql).all(params);
17
+ },
18
+ async exec(sql, params = []) {
19
+ return { changes: sqlite.prepare(sql).run(params).changes };
20
+ },
21
+ async close() {
22
+ sqlite.close();
23
+ },
24
+ };
25
+ changes = new change_tracking_1.ChangeTrackingTable({ db });
26
+ });
27
+ afterEach(() => sqlite.close());
28
+ const change = (tableName, recordId) => ({
29
+ changeId: (0, utils_1.newid)(),
30
+ tableName,
31
+ recordId,
32
+ op: "set",
33
+ path: "/",
34
+ value: null,
35
+ createdAt: Date.now(),
36
+ appliedAt: 0,
37
+ });
38
+ it("checks missing IDs within the requested table, preserving unscoped lookups", async () => {
39
+ const sharedId = (0, utils_1.newid)();
40
+ const trackedId = (0, utils_1.newid)();
41
+ const missingId = (0, utils_1.newid)();
42
+ await changes.insert(change("Other", sharedId));
43
+ await changes.insert(change("State", trackedId));
44
+ await expect(changes.filterToMissingIds([sharedId, trackedId, missingId], "State")).resolves.toEqual([sharedId, missingId]);
45
+ await expect(changes.filterToMissingIds([sharedId, trackedId, missingId])).resolves.toEqual([
46
+ missingId,
47
+ ]);
48
+ await expect(changes.filterToMissingIds([], "State")).resolves.toEqual([]);
49
+ });
50
+ it("deletes active, superseded, and deleted history only for the named table", async () => {
51
+ const tableName = "State'with-quote";
52
+ const recordId = (0, utils_1.newid)();
53
+ const other = change("Other", recordId);
54
+ await changes.insert(change(tableName, recordId));
55
+ await changes.insert({ ...change(tableName, recordId), supersededAt: Date.now() });
56
+ await changes.insert({ ...change(tableName, recordId), op: "delete" });
57
+ await changes.insert(other);
58
+ await changes.deleteTableChanges(tableName);
59
+ await changes.deleteTableChanges(tableName);
60
+ expect(await changes.list()).toEqual([expect.objectContaining({ changeId: other.changeId })]);
61
+ });
62
+ });
@@ -51,7 +51,7 @@ async function createGroup(userContext, input) {
51
51
  });
52
52
  await (0, groups_1.Groups)(groupDataContext).signAndSave(group);
53
53
  await (0, group_members_1.GroupMembers)(groupDataContext).signAndSave({
54
- groupMemberId: (0, utils_1.newid)(),
54
+ groupMemberId: (0, utils_1.deterministicGroupMemberId)(groupId, me.userId),
55
55
  groupId,
56
56
  userId: me.userId,
57
57
  role: group_member_roles_1.GroupMemberRole.Founder,
@@ -36,6 +36,20 @@ export declare class GroupMembersTable extends Table<IGroupMember> {
36
36
  save(groupMember: IGroupMember, opts?: ISaveOptions): Promise<IGroupMember>;
37
37
  signAndSave(groupMember: IGroupMember, opts?: ISaveOptions): Promise<IGroupMember>;
38
38
  delete(groupMemberId: string): Promise<void>;
39
+ /**
40
+ * Settle two membership rows for the same user in the same group: devices that approved the
41
+ * same join at once could each write one, under a different id.
42
+ *
43
+ * Merged only when both rows grant the same access (role and deleted state), so nothing about
44
+ * the member's access changes. The id {@link deterministicGroupMemberId} gives wins if either
45
+ * row has it, otherwise the lexicographically smaller id; the winner is kept as it is, signature
46
+ * included. Rows that disagree stay queued for a person. A pure function of the two rows, so
47
+ * every device keeps the same one.
48
+ */
49
+ resolveUniqueConflict(incoming: IGroupMember, existing: IGroupMember): {
50
+ winner: IGroupMember;
51
+ loserId: string;
52
+ } | undefined;
39
53
  /** Registers the signer for all SDK copies in this JavaScript realm. Replaces any prior signer. */
40
54
  static enableGroupMemberSigning(fn: (groupMember: IGroupMember) => IGroupMember): void;
41
55
  /** @deprecated Forbidden on GroupsTable; use save() */
@@ -40,6 +40,7 @@ const zod_1 = require("zod");
40
40
  const user_context_singleton_1 = require("../context/user-context-singleton");
41
41
  const decorators_1 = require("../data/orm/decorators");
42
42
  const zod_types_1 = require("../types/zod-types");
43
+ const utils_1 = require("../utils");
43
44
  const group_member_roles_1 = require("./group-member-roles");
44
45
  const group_permissions_1 = require("./group-permissions");
45
46
  const table_1 = require("./orm/table");
@@ -125,6 +126,41 @@ let GroupMembersTable = (() => {
125
126
  await this.signAndSave(groupMember);
126
127
  }
127
128
  }
129
+ /**
130
+ * Settle two membership rows for the same user in the same group: devices that approved the
131
+ * same join at once could each write one, under a different id.
132
+ *
133
+ * Merged only when both rows grant the same access (role and deleted state), so nothing about
134
+ * the member's access changes. The id {@link deterministicGroupMemberId} gives wins if either
135
+ * row has it, otherwise the lexicographically smaller id; the winner is kept as it is, signature
136
+ * included. Rows that disagree stay queued for a person. A pure function of the two rows, so
137
+ * every device keeps the same one.
138
+ */
139
+ resolveUniqueConflict(incoming, existing) {
140
+ if (incoming.groupId !== existing.groupId || incoming.userId !== existing.userId) {
141
+ return undefined;
142
+ }
143
+ if (incoming.groupMemberId === existing.groupMemberId) {
144
+ return undefined;
145
+ }
146
+ if (incoming.role !== existing.role ||
147
+ Boolean(incoming.deleted) !== Boolean(existing.deleted)) {
148
+ return undefined;
149
+ }
150
+ const derivedId = (0, utils_1.deterministicGroupMemberId)(incoming.groupId, incoming.userId);
151
+ let winner;
152
+ if (incoming.groupMemberId === derivedId) {
153
+ winner = incoming;
154
+ }
155
+ else if (existing.groupMemberId === derivedId) {
156
+ winner = existing;
157
+ }
158
+ else {
159
+ winner = incoming.groupMemberId < existing.groupMemberId ? incoming : existing;
160
+ }
161
+ const loser = winner === incoming ? existing : incoming;
162
+ return { winner, loserId: loser.groupMemberId };
163
+ }
128
164
  /** Registers the signer for all SDK copies in this JavaScript realm. Replaces any prior signer. */
129
165
  static enableGroupMemberSigning(fn) {
130
166
  signingGlobals[signingKey] = fn;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const keys_1 = require("../keys");
4
+ const utils_1 = require("../utils");
5
+ const group_members_1 = require("./group-members");
4
6
  const group_permissions_1 = require("./group-permissions");
5
7
  // Test the group members signature logic directly without ORM dependencies
6
8
  describe("Group Members Signature Logic", () => {
@@ -285,3 +287,50 @@ describe("Group Members Signature Logic", () => {
285
287
  });
286
288
  });
287
289
  });
290
+ describe("GroupMembersTable.resolveUniqueConflict", () => {
291
+ const groupId = (0, utils_1.newid)();
292
+ const userId = (0, utils_1.newid)();
293
+ const resolve = (incoming, existing) => group_members_1.GroupMembersTable.prototype.resolveUniqueConflict.call({}, incoming, existing);
294
+ const row = (groupMemberId, overrides = {}) => ({
295
+ groupMemberId,
296
+ groupId,
297
+ userId,
298
+ role: group_permissions_1.GroupMemberRole.Writer,
299
+ signature: `sig-${groupMemberId}`,
300
+ ...overrides,
301
+ });
302
+ it("keeps the smaller id, unchanged, whichever row arrives", () => {
303
+ const [low, high] = [(0, utils_1.newid)(), (0, utils_1.newid)()].sort();
304
+ const a = row(low);
305
+ const b = row(high);
306
+ expect(resolve(a, b)).toEqual({ winner: a, loserId: high });
307
+ expect(resolve(b, a)).toEqual({ winner: a, loserId: high });
308
+ expect(resolve(b, a)?.winner.signature).toBe(`sig-${low}`);
309
+ });
310
+ it("prefers the id derived from the group and user", () => {
311
+ const derived = row((0, utils_1.deterministicGroupMemberId)(groupId, userId));
312
+ const other = row("00000000000000000000000001");
313
+ expect(resolve(other, derived)).toEqual({ winner: derived, loserId: other.groupMemberId });
314
+ expect(resolve(derived, other)).toEqual({ winner: derived, loserId: other.groupMemberId });
315
+ });
316
+ it("leaves rows that grant different access for a person to settle", () => {
317
+ const a = row((0, utils_1.newid)());
318
+ expect(resolve(a, row((0, utils_1.newid)(), { role: group_permissions_1.GroupMemberRole.Admin }))).toBeUndefined();
319
+ expect(resolve(a, row((0, utils_1.newid)(), { deleted: true }))).toBeUndefined();
320
+ expect(resolve(a, row((0, utils_1.newid)(), { userId: (0, utils_1.newid)() }))).toBeUndefined();
321
+ expect(resolve(a, row((0, utils_1.newid)(), { groupId: (0, utils_1.newid)() }))).toBeUndefined();
322
+ expect(resolve(a, { ...a })).toBeUndefined();
323
+ });
324
+ it("treats a missing deleted flag as not deleted", () => {
325
+ const a = row((0, utils_1.newid)());
326
+ const b = row((0, utils_1.newid)(), { deleted: false });
327
+ expect(resolve(a, b)).toBeDefined();
328
+ });
329
+ it("depends only on the two signed rows and never modifies either one", () => {
330
+ const a = Object.freeze(row((0, utils_1.newid)()));
331
+ const b = Object.freeze(row((0, utils_1.newid)()));
332
+ const before = [structuredClone(a), structuredClone(b)];
333
+ expect(resolve(a, b)).toEqual(resolve(b, a));
334
+ expect([a, b]).toEqual(before);
335
+ });
336
+ });
@@ -42,6 +42,15 @@ export declare class SQLDataSource<T extends {
42
42
  * columns (for example `accessLevel`) are written on insert.
43
43
  */
44
44
  private applySchemaDefaults;
45
+ /**
46
+ * Remove `record`'s empty fields in place, so a row is written and returned as it's stored:
47
+ * an optional field that is `null` or `undefined` is left out, and so is an empty field with a
48
+ * default, which gets the default. Returns the defaulted fields' names. A required field
49
+ * without a default keeps its value for validation to refuse.
50
+ */
51
+ private removeEmptyFields;
52
+ /** Copy the parsed defaults requested by {@link removeEmptyFields} onto `record`. */
53
+ private applyDefaults;
45
54
  /**
46
55
  * Columns to SET on update. Required fields that are missing are omitted so
47
56
  * older peers without a new column do not overwrite a local value with NULL.
@@ -258,6 +258,7 @@ class SQLDataSource {
258
258
  // @ts-expect-error - need to figure out how to get typescript to understand what's going on here
259
259
  record[this.metaData.primaryKeyName] = (0, utils_1.newid)();
260
260
  }
261
+ this.removeEmptyFields(record);
261
262
  this.applySchemaDefaults(record);
262
263
  const fields = this.metaData.fields.map((field) => `"${field.name}"`).join(", ");
263
264
  const values = this.metaData.fields.map((field) => jsValueToSqlValue(record[field.name]));
@@ -276,8 +277,9 @@ class SQLDataSource {
276
277
  // Explicit catalog patches write only their own columns. Keep ordinary row-save
277
278
  // semantics (omitted optional fields clear) unchanged for all other writes.
278
279
  const catalogPatch = this.tableName === "Tools" && (0, tool_catalog_fields_1.isToolCatalogPatch)(record);
280
+ const defaulted = catalogPatch ? [] : this.removeEmptyFields(record);
279
281
  const updated = catalogPatch ? { ...(await this.get(primaryKey)), ...record } : record;
280
- this.schema.parse(updated);
282
+ this.applyDefaults(record, this.schema.parse(updated), defaulted);
281
283
  const updateFields = this.fieldsForUpdate(record).filter((field) => !catalogPatch || Object.hasOwn(record, field.name));
282
284
  if (updateFields.length === 0) {
283
285
  return updated;
@@ -327,6 +329,47 @@ class SQLDataSource {
327
329
  }
328
330
  }
329
331
  }
332
+ /**
333
+ * Remove `record`'s empty fields in place, so a row is written and returned as it's stored:
334
+ * an optional field that is `null` or `undefined` is left out, and so is an empty field with a
335
+ * default, which gets the default. Returns the defaulted fields' names. A required field
336
+ * without a default keeps its value for validation to refuse.
337
+ */
338
+ removeEmptyFields(record) {
339
+ const writable = record;
340
+ const cleaned = (0, types_1.withoutEmptyFields)(this.metaData.fields, writable);
341
+ const defaulted = [];
342
+ for (const field of this.metaData.fields) {
343
+ // A missing optional column is cleared on update too. Its default must win over NULL;
344
+ // a missing required column still keeps the stored value for older syncing peers.
345
+ if (field.defaultValue !== undefined &&
346
+ writable[field.name] == null &&
347
+ (Object.hasOwn(writable, field.name) || field.optional)) {
348
+ defaulted.push(field.name);
349
+ }
350
+ if (!Object.hasOwn(writable, field.name)) {
351
+ continue;
352
+ }
353
+ if (!Object.hasOwn(cleaned, field.name)) {
354
+ delete writable[field.name];
355
+ }
356
+ else if (cleaned[field.name] !== writable[field.name]) {
357
+ writable[field.name] = cleaned[field.name];
358
+ }
359
+ }
360
+ return defaulted;
361
+ }
362
+ /** Copy the parsed defaults requested by {@link removeEmptyFields} onto `record`. */
363
+ applyDefaults(record, parsed, names) {
364
+ for (const name of names) {
365
+ if (parsed[name] === undefined) {
366
+ delete record[name];
367
+ }
368
+ else {
369
+ record[name] = parsed[name];
370
+ }
371
+ }
372
+ }
330
373
  /**
331
374
  * Columns to SET on update. Required fields that are missing are omitted so
332
375
  * older peers without a new column do not overwrite a local value with NULL.
@@ -366,6 +409,7 @@ class SQLDataSource {
366
409
  // @ts-expect-error - need to figure out how to get typescript to understand what's going on here
367
410
  record[this.metaData.primaryKeyName] = (0, utils_1.newid)();
368
411
  }
412
+ this.removeEmptyFields(record);
369
413
  this.applySchemaDefaults(record);
370
414
  const fields = this.metaData.fields.map((field) => `"${field.name}"`).join(", ");
371
415
  const values = this.metaData.fields.map((field) => jsValueToSqlValue(record[field.name]));
@@ -383,7 +427,8 @@ class SQLDataSource {
383
427
  // @ts-expect-error - need to figure out how to get typescript to understand what's going on here
384
428
  record[this.metaData.primaryKeyName] = (0, utils_1.newid)();
385
429
  }
386
- this.schema.parse(record);
430
+ const defaulted = this.removeEmptyFields(record);
431
+ this.applyDefaults(record, this.schema.parse(record), defaulted);
387
432
  const primaryKey = record[this.metaData.primaryKeyName];
388
433
  const updateFields = this.fieldsForUpdate(record);
389
434
  if (updateFields.length === 0) {