@interocitor/core 0.0.0-beta.3 → 0.0.0-beta.5

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 (98) hide show
  1. package/README.md +446 -91
  2. package/dist/adapters/cloudflare.d.ts +8 -9
  3. package/dist/adapters/cloudflare.d.ts.map +1 -1
  4. package/dist/adapters/cloudflare.js +38 -12
  5. package/dist/adapters/google-drive.d.ts +1 -1
  6. package/dist/adapters/google-drive.js +1 -2
  7. package/dist/adapters/memory.d.ts +4 -1
  8. package/dist/adapters/memory.d.ts.map +1 -1
  9. package/dist/adapters/memory.js +13 -2
  10. package/dist/adapters/webdav.d.ts +5 -0
  11. package/dist/adapters/webdav.d.ts.map +1 -1
  12. package/dist/adapters/webdav.js +18 -1
  13. package/dist/core/codec.d.ts +1 -1
  14. package/dist/core/codec.d.ts.map +1 -1
  15. package/dist/core/codec.js +39 -3
  16. package/dist/core/compaction.d.ts +9 -1
  17. package/dist/core/compaction.d.ts.map +1 -1
  18. package/dist/core/compaction.js +63 -7
  19. package/dist/core/crdt.d.ts +6 -3
  20. package/dist/core/crdt.d.ts.map +1 -1
  21. package/dist/core/crdt.js +53 -67
  22. package/dist/core/errors.d.ts +47 -0
  23. package/dist/core/errors.d.ts.map +1 -0
  24. package/dist/core/errors.js +61 -0
  25. package/dist/core/flush.d.ts +3 -3
  26. package/dist/core/flush.d.ts.map +1 -1
  27. package/dist/core/flush.js +64 -7
  28. package/dist/core/hlc.js +0 -1
  29. package/dist/core/ids.d.ts +49 -0
  30. package/dist/core/ids.d.ts.map +1 -0
  31. package/dist/core/ids.js +132 -0
  32. package/dist/core/internals.d.ts +10 -2
  33. package/dist/core/internals.d.ts.map +1 -1
  34. package/dist/core/internals.js +27 -9
  35. package/dist/core/manifest.d.ts +24 -5
  36. package/dist/core/manifest.d.ts.map +1 -1
  37. package/dist/core/manifest.js +80 -13
  38. package/dist/core/pull.d.ts +1 -1
  39. package/dist/core/pull.d.ts.map +1 -1
  40. package/dist/core/pull.js +21 -6
  41. package/dist/core/row-id.js +0 -1
  42. package/dist/core/schema-types.d.ts +22 -11
  43. package/dist/core/schema-types.d.ts.map +1 -1
  44. package/dist/core/schema-types.js +18 -9
  45. package/dist/core/schema-types.type-test.js +59 -5
  46. package/dist/core/sync-engine.d.ts +166 -12
  47. package/dist/core/sync-engine.d.ts.map +1 -1
  48. package/dist/core/sync-engine.js +1615 -219
  49. package/dist/core/table.d.ts +217 -17
  50. package/dist/core/table.d.ts.map +1 -1
  51. package/dist/core/table.js +376 -24
  52. package/dist/core/types.d.ts +413 -17
  53. package/dist/core/types.d.ts.map +1 -1
  54. package/dist/core/types.js +0 -1
  55. package/dist/crypto/encryption.d.ts.map +1 -1
  56. package/dist/crypto/encryption.js +6 -1
  57. package/dist/crypto/keys.js +0 -1
  58. package/dist/handshake/channel.js +0 -1
  59. package/dist/handshake/index.d.ts +5 -2
  60. package/dist/handshake/index.d.ts.map +1 -1
  61. package/dist/handshake/index.js +19 -2
  62. package/dist/handshake/qr.js +0 -1
  63. package/dist/index.d.ts +9 -7
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +8 -6
  66. package/dist/storage/credential-store.d.ts +25 -2
  67. package/dist/storage/credential-store.d.ts.map +1 -1
  68. package/dist/storage/credential-store.js +55 -8
  69. package/dist/storage/local-store.d.ts +4 -1
  70. package/dist/storage/local-store.d.ts.map +1 -1
  71. package/dist/storage/local-store.js +37 -21
  72. package/package.json +3 -3
  73. package/dist/adapters/cloudflare.js.map +0 -1
  74. package/dist/adapters/google-drive.js.map +0 -1
  75. package/dist/adapters/memory.js.map +0 -1
  76. package/dist/adapters/webdav.js.map +0 -1
  77. package/dist/core/codec.js.map +0 -1
  78. package/dist/core/compaction.js.map +0 -1
  79. package/dist/core/crdt.js.map +0 -1
  80. package/dist/core/flush.js.map +0 -1
  81. package/dist/core/hlc.js.map +0 -1
  82. package/dist/core/internals.js.map +0 -1
  83. package/dist/core/manifest.js.map +0 -1
  84. package/dist/core/pull.js.map +0 -1
  85. package/dist/core/row-id.js.map +0 -1
  86. package/dist/core/schema-types.js.map +0 -1
  87. package/dist/core/schema-types.type-test.js.map +0 -1
  88. package/dist/core/sync-engine.js.map +0 -1
  89. package/dist/core/table.js.map +0 -1
  90. package/dist/core/types.js.map +0 -1
  91. package/dist/crypto/encryption.js.map +0 -1
  92. package/dist/crypto/keys.js.map +0 -1
  93. package/dist/handshake/channel.js.map +0 -1
  94. package/dist/handshake/index.js.map +0 -1
  95. package/dist/handshake/qr.js.map +0 -1
  96. package/dist/index.js.map +0 -1
  97. package/dist/storage/credential-store.js.map +0 -1
  98. package/dist/storage/local-store.js.map +0 -1
package/dist/core/crdt.js CHANGED
@@ -10,10 +10,12 @@
10
10
  * - custom function — `(local, remote, ctx) => ColumnEntry`
11
11
  *
12
12
  * Deletes are soft (tombstone with HLC) and always use LWW.
13
+ *
14
+ * Row shape (`{_meta, payload}`) keeps user payload fully isolated from
15
+ * engine metadata. The merge loop only touches `row.payload`; `row._meta`
16
+ * is never indexed by user-controlled keys.
13
17
  */
14
18
  import { hlcCompareStr } from "./hlc.js";
15
- /** Reserved keys that are not user columns */
16
- const META_KEYS = new Set(['_table', '_rowId', '_deleted', '_deletedHlc', '_schemaVersion']);
17
19
  /**
18
20
  * Resolve the merge strategy for a specific column.
19
21
  *
@@ -25,7 +27,6 @@ function resolveStrategy(schema, table, field) {
25
27
  if (tableDef?.merge) {
26
28
  const m = tableDef.merge;
27
29
  if (typeof m === 'object' && 'fields' in m) {
28
- // TableMergeConfig
29
30
  const config = m;
30
31
  if (config.fields?.[field])
31
32
  return config.fields[field];
@@ -33,12 +34,9 @@ function resolveStrategy(schema, table, field) {
33
34
  return config.strategy;
34
35
  }
35
36
  else {
36
- // bare MergeStrategy (string or function) on the table
37
37
  return m;
38
38
  }
39
39
  }
40
- // No schema at all → LWW (backwards compat for raw applyOp callers).
41
- // Schema present but no mergeStrategy → remote-wins (sensible default).
42
40
  if (!schema)
43
41
  return 'lww';
44
42
  return schema.mergeStrategy ?? 'remote-wins';
@@ -50,26 +48,28 @@ function resolveStrategy(schema, table, field) {
50
48
  * regardless of strategy — there's no conflict.
51
49
  */
52
50
  function mergeColumn(local, remote, strategy, table, rowId, field) {
53
- // No local value → accept remote unconditionally
54
51
  if (!local || !local.hlc)
55
52
  return remote;
56
53
  if (typeof strategy === 'function') {
57
54
  const result = strategy(local, remote, { table, rowId, field });
58
- // Only count as changed if the result differs from local
59
55
  return result.hlc !== local.hlc || result.value !== local.value ? result : null;
60
56
  }
61
57
  switch (strategy) {
62
58
  case 'remote-wins':
63
59
  return remote;
64
60
  case 'local-wins':
65
- // Only accept remote if it's strictly newer (no conflict — local
66
- // hasn't written this column yet at this HLC).
67
- // When both have values, local keeps its value.
68
61
  return null;
69
62
  default:
70
63
  return hlcCompareStr(remote.hlc, local.hlc) > 0 ? remote : null;
71
64
  }
72
65
  }
66
+ /** Build a fresh row stub. */
67
+ function blankRow(table, rowId, schemaVersion, deleted = false, deletedHlc) {
68
+ return {
69
+ _meta: { table, rowId, deleted, deletedHlc, schemaVersion },
70
+ payload: {},
71
+ };
72
+ }
73
73
  /**
74
74
  * Apply a single op to the in-memory state.
75
75
  * Returns the affected row (mutated in place) or null if no change.
@@ -82,63 +82,58 @@ export function applyOp(tables, op, schemaVersion, schema) {
82
82
  if (op.type === 'delete') {
83
83
  const existing = table[op.rowId];
84
84
  if (existing) {
85
- // Only apply delete if its HLC is newer than all column HLCs
86
- if (existing._deletedHlc && hlcCompareStr(op.hlc, existing._deletedHlc) <= 0) {
87
- return null; // stale delete
85
+ // Stale delete (older than current tombstone)?
86
+ if (existing._meta.deletedHlc && hlcCompareStr(op.hlc, existing._meta.deletedHlc) <= 0) {
87
+ return null;
88
88
  }
89
- // Check if any column has a newer HLC than this delete
90
- const hasNewerColumn = Object.entries(existing).some(([key, val]) => {
91
- if (META_KEYS.has(key))
92
- return false;
93
- const entry = val;
89
+ // Any payload column newer than this delete? Then delete loses.
90
+ const hasNewerColumn = Object.values(existing.payload).some(entry => {
94
91
  return entry?.hlc && hlcCompareStr(entry.hlc, op.hlc) > 0;
95
92
  });
96
93
  if (hasNewerColumn)
97
94
  return null;
98
- existing._deleted = true;
99
- existing._deletedHlc = op.hlc;
95
+ existing._meta.deleted = true;
96
+ existing._meta.deletedHlc = op.hlc;
97
+ // A tombstone only needs deletedHlc for future conflict checks. Keeping
98
+ // payload columns wastes storage and can leak pre-delete values into
99
+ // future row incarnations.
100
+ existing.payload = {};
100
101
  return existing;
101
102
  }
102
- // Tombstone for a row we haven't seen — create it
103
- const row = {
104
- _table: op.table,
105
- _rowId: op.rowId,
106
- _deleted: true,
107
- _deletedHlc: op.hlc,
108
- _schemaVersion: schemaVersion,
109
- };
103
+ // Tombstone for unseen row.
104
+ const row = blankRow(op.table, op.rowId, schemaVersion, true, op.hlc);
110
105
  table[op.rowId] = row;
111
106
  return row;
112
107
  }
113
- // Upsert
108
+ // Upsert.
114
109
  let row = table[op.rowId];
115
110
  let changed = false;
116
111
  if (!row) {
117
- row = {
118
- _table: op.table,
119
- _rowId: op.rowId,
120
- _deleted: false,
121
- _schemaVersion: schemaVersion,
122
- };
112
+ row = blankRow(op.table, op.rowId, schemaVersion);
123
113
  table[op.rowId] = row;
124
114
  changed = true;
125
115
  }
126
- for (const [col, entry] of Object.entries(op.columns)) {
127
- const existing = row[col];
116
+ let columnsToApply = Object.entries(op.columns);
117
+ // Resurrection creates a new row incarnation. The tombstone wins over every
118
+ // payload column at or before deletedHlc, including columns retained on the
119
+ // local tombstone and stale columns bundled in a remote upsert. Otherwise a
120
+ // partial insert after delete can republish pre-delete fields forever.
121
+ if (row._meta.deleted && row._meta.deletedHlc) {
122
+ const deletedHlc = row._meta.deletedHlc;
123
+ columnsToApply = columnsToApply.filter(([, entry]) => hlcCompareStr(entry.hlc, deletedHlc) > 0);
124
+ if (columnsToApply.length === 0)
125
+ return null;
126
+ row.payload = {};
127
+ row._meta.deleted = false;
128
+ row._meta.deletedHlc = undefined;
129
+ changed = true;
130
+ }
131
+ for (const [col, entry] of columnsToApply) {
132
+ const existing = row.payload[col];
128
133
  const strategy = resolveStrategy(schema, op.table, col);
129
134
  const winner = mergeColumn(existing, entry, strategy, op.table, op.rowId, col);
130
135
  if (winner) {
131
- row[col] = winner;
132
- changed = true;
133
- }
134
- }
135
- // An upsert that's newer than a delete revives the row
136
- if (row._deleted && row._deletedHlc) {
137
- const newestOpHlc = Object.values(op.columns).reduce((max, entry) => {
138
- return !max || hlcCompareStr(entry.hlc, max) > 0 ? entry.hlc : max;
139
- }, '');
140
- if (newestOpHlc && hlcCompareStr(newestOpHlc, row._deletedHlc) > 0) {
141
- row._deleted = false;
136
+ row.payload[col] = winner;
142
137
  changed = true;
143
138
  }
144
139
  }
@@ -157,32 +152,23 @@ export function applyChangeEntry(tables, entry, schemaVersion, schema) {
157
152
  }
158
153
  return affected;
159
154
  }
160
- /**
161
- * Read a column value from a row, unwrapping the ColumnEntry.
162
- */
155
+ /** Read a column value from a row, unwrapping the ColumnEntry. */
163
156
  export function readColumn(row, column) {
164
- const entry = row[column];
165
- if (entry && typeof entry === 'object' && 'value' in entry && 'hlc' in entry) {
166
- return entry.value;
167
- }
168
- return undefined;
157
+ const entry = row.payload?.[column];
158
+ return entry?.value;
169
159
  }
170
160
  /**
171
161
  * Build a plain object from a row (strip HLC metadata).
162
+ * Returns user-facing fields with `_meta` projection (table, rowId, deleted).
172
163
  */
173
164
  export function rowToPlain(row) {
174
165
  const result = {
175
- _table: row._table,
176
- _rowId: row._rowId,
177
- _deleted: row._deleted,
166
+ _table: row._meta.table,
167
+ _rowId: row._meta.rowId,
168
+ _deleted: row._meta.deleted,
178
169
  };
179
- for (const [key, val] of Object.entries(row)) {
180
- if (META_KEYS.has(key))
181
- continue;
182
- if (val && typeof val === 'object' && 'value' in val && 'hlc' in val) {
183
- result[key] = val.value;
184
- }
170
+ for (const [key, entry] of Object.entries(row.payload)) {
171
+ result[key] = entry.value;
185
172
  }
186
173
  return result;
187
174
  }
188
- //# sourceMappingURL=crdt.js.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Typed error classes for the Interocitor engine.
3
+ *
4
+ * Callers should prefer `instanceof` over message/code string matching.
5
+ * Every typed error keeps its `code` field stable across releases — it
6
+ * is part of the public contract.
7
+ */
8
+ /**
9
+ * Thrown by `connect()` when the engine's `encrypted` flag does not
10
+ * match the encryption mode the remote mesh was bootstrapped with.
11
+ *
12
+ * Common cause: the app constructs the engine with `encrypted: false`
13
+ * on first run (e.g. before the user supplies a passphrase) and later
14
+ * reconnects with `encrypted: true`. The remote is healthy and is
15
+ * NOT poisoned by this error — the local engine config is wrong.
16
+ *
17
+ * Recovery: rebuild the engine with `encrypted` set to `expectedMode`
18
+ * and supply the matching passphrase when `expectedMode === true`.
19
+ */
20
+ /**
21
+ * Thrown by `connect()` when the credential store has a record under the
22
+ * engine's `dbName` but the stored `meshId` does not match the live mesh
23
+ * the engine is connecting to.
24
+ *
25
+ * Common cause: app reuses the same `dbName` for "create new mesh" — the
26
+ * old record (passphrase + deviceId for the previous mesh) survives the
27
+ * recreate. Silently reusing the old key would either fail to decrypt
28
+ * the new mesh's files or, worse, encrypt new writes under the wrong
29
+ * key and poison the remote.
30
+ *
31
+ * Recovery: the caller decides — either `clearCredentials()` and retry,
32
+ * or change `dbName` so the two meshes have isolated credential stores.
33
+ */
34
+ export declare class MeshCredentialMismatchError extends Error {
35
+ readonly code: "MESH_CREDENTIAL_MISMATCH";
36
+ readonly dbName: string;
37
+ readonly storedMeshId: string;
38
+ readonly activeMeshId: string;
39
+ constructor(dbName: string, storedMeshId: string, activeMeshId: string);
40
+ }
41
+ export declare class MeshEncryptionMismatchError extends Error {
42
+ readonly code: "MESH_ENCRYPTION_MISMATCH";
43
+ readonly expectedMode: boolean;
44
+ readonly actualMode: boolean;
45
+ constructor(expectedMode: boolean, actualMode: boolean);
46
+ }
47
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/core/errors.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;;;GAWG;AACH;;;;;;;;;;;;;GAaG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;IACpD,QAAQ,CAAC,IAAI,EAAG,0BAA0B,CAAU;IACpD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;gBAElB,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM;CAavE;AAED,qBAAa,2BAA4B,SAAQ,KAAK;IACpD,QAAQ,CAAC,IAAI,EAAG,0BAA0B,CAAU;IACpD,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;gBAEjB,YAAY,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO;CAavD"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Typed error classes for the Interocitor engine.
3
+ *
4
+ * Callers should prefer `instanceof` over message/code string matching.
5
+ * Every typed error keeps its `code` field stable across releases — it
6
+ * is part of the public contract.
7
+ */
8
+ /**
9
+ * Thrown by `connect()` when the engine's `encrypted` flag does not
10
+ * match the encryption mode the remote mesh was bootstrapped with.
11
+ *
12
+ * Common cause: the app constructs the engine with `encrypted: false`
13
+ * on first run (e.g. before the user supplies a passphrase) and later
14
+ * reconnects with `encrypted: true`. The remote is healthy and is
15
+ * NOT poisoned by this error — the local engine config is wrong.
16
+ *
17
+ * Recovery: rebuild the engine with `encrypted` set to `expectedMode`
18
+ * and supply the matching passphrase when `expectedMode === true`.
19
+ */
20
+ /**
21
+ * Thrown by `connect()` when the credential store has a record under the
22
+ * engine's `dbName` but the stored `meshId` does not match the live mesh
23
+ * the engine is connecting to.
24
+ *
25
+ * Common cause: app reuses the same `dbName` for "create new mesh" — the
26
+ * old record (passphrase + deviceId for the previous mesh) survives the
27
+ * recreate. Silently reusing the old key would either fail to decrypt
28
+ * the new mesh's files or, worse, encrypt new writes under the wrong
29
+ * key and poison the remote.
30
+ *
31
+ * Recovery: the caller decides — either `clearCredentials()` and retry,
32
+ * or change `dbName` so the two meshes have isolated credential stores.
33
+ */
34
+ export class MeshCredentialMismatchError extends Error {
35
+ constructor(dbName, storedMeshId, activeMeshId) {
36
+ super(`Stored credentials under dbName="${dbName}" belong to meshId="${storedMeshId}" ` +
37
+ `but the active mesh is meshId="${activeMeshId}". ` +
38
+ `Refusing to silently reuse the wrong key. ` +
39
+ `Call engine.clearCredentials() to drop the stale record, ` +
40
+ `or use a different dbName for the new mesh.`);
41
+ this.code = 'MESH_CREDENTIAL_MISMATCH';
42
+ this.name = 'MeshCredentialMismatchError';
43
+ this.dbName = dbName;
44
+ this.storedMeshId = storedMeshId;
45
+ this.activeMeshId = activeMeshId;
46
+ }
47
+ }
48
+ export class MeshEncryptionMismatchError extends Error {
49
+ constructor(expectedMode, actualMode) {
50
+ super(`Mesh encryption mode mismatch: remote mesh was bootstrapped with ` +
51
+ `encrypted=${expectedMode} but this engine was created with ` +
52
+ `encrypted=${actualMode}. Recreate the Interocitor instance with ` +
53
+ `encrypted=${expectedMode}` +
54
+ (expectedMode ? ' and supply the matching passphrase' : '') +
55
+ `, or join a fresh mesh. Remote was NOT poisoned.`);
56
+ this.code = 'MESH_ENCRYPTION_MISMATCH';
57
+ this.name = 'MeshEncryptionMismatchError';
58
+ this.expectedMode = expectedMode;
59
+ this.actualMode = actualMode;
60
+ }
61
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Flush — push queued local outbox entries to remote cloud.
3
3
  *
4
- * Extracted from SyncEngine. Not part of the public API.
4
+ * Extracted from Interocitor. Not part of the public API.
5
5
  */
6
- import type { StorageAdapter, ChangeEntry } from './types.ts';
6
+ import type { StorageAdapter, ChangeEntry, SyncEvent } from './types.ts';
7
7
  import type { CodecState } from './codec.ts';
8
- export declare function flushToAdapter(adapter: StorageAdapter, remotePath: string, entries: ChangeEntry[], isPrimary: boolean, codecState: CodecState, deviceId: string): Promise<void>;
8
+ export declare function flushToAdapter(adapter: StorageAdapter, remotePath: string, entries: ChangeEntry[], isPrimary: boolean, codecState: CodecState, deviceId: string, emit?: (event: SyncEvent) => void): Promise<void>;
9
9
  //# sourceMappingURL=flush.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"flush.d.ts","sourceRoot":"","sources":["../../src/core/flush.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EACV,cAAc,EACd,WAAW,EAEZ,MAAM,YAAY,CAAC;AAIpB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAG7C,wBAAsB,cAAc,CAClC,OAAO,EAAE,cAAc,EACvB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,WAAW,EAAE,EACtB,SAAS,EAAE,OAAO,EAClB,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAsCf"}
1
+ {"version":3,"file":"flush.d.ts","sourceRoot":"","sources":["../../src/core/flush.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EACV,cAAc,EACd,WAAW,EAEX,SAAS,EACV,MAAM,YAAY,CAAC;AAIpB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAG7C,wBAAsB,cAAc,CAClC,OAAO,EAAE,cAAc,EACvB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,WAAW,EAAE,EACtB,SAAS,EAAE,OAAO,EAClB,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAe,GAC1C,OAAO,CAAC,IAAI,CAAC,CAqGf"}
@@ -1,25 +1,46 @@
1
1
  /**
2
2
  * Flush — push queued local outbox entries to remote cloud.
3
3
  *
4
- * Extracted from SyncEngine. Not part of the public API.
4
+ * Extracted from Interocitor. Not part of the public API.
5
5
  */
6
6
  import { hlcCompareStr } from "./hlc.js";
7
7
  import { paths, textEncoder, textDecoder } from "./internals.js";
8
8
  import { encodeChangePayload } from "./codec.js";
9
9
  import { upsertDeviceMetadata } from "./manifest.js";
10
- export async function flushToAdapter(adapter, remotePath, entries, isPrimary, codecState, deviceId) {
10
+ export async function flushToAdapter(adapter, remotePath, entries, isPrimary, codecState, deviceId, emit = () => { }) {
11
11
  const p = paths(remotePath);
12
12
  await adapter.ensureFolder(p.changesFolder);
13
13
  let lastWrittenHlc = '';
14
14
  for (const entry of entries) {
15
15
  const fileName = `${entry.hlc}-${entry.id}.json`;
16
16
  const payload = await encodeChangePayload(codecState, entry);
17
+ console.log('[interocitor:write] flush.changeFile', { path: p.changeFile(fileName), deviceId, isPrimary, entryId: entry.id, hlc: entry.hlc });
17
18
  await adapter.writeFile(p.changeFile(fileName), textEncoder.encode(payload));
18
19
  if (!lastWrittenHlc || hlcCompareStr(entry.hlc, lastWrittenHlc) > 0) {
19
20
  lastWrittenHlc = entry.hlc;
20
21
  }
21
22
  }
22
23
  // Update global head — monotonic HLC hint for fast poll skipping.
24
+ // Tracing rules (see SyncEvent `trace:head`):
25
+ // - read → emit { op: 'read', reason: 'flush', priorHlc }
26
+ // - write strictly forward → emit { op: 'write', priorHlc, nextHlc }
27
+ // - write where nextHlc <= priorHlc → emit
28
+ // { op: 'write', regressed: true } AND skip the writeFile
29
+ // (regression = bug signal; never let head go backwards on disk).
30
+ // - no entries to flush → emit { op: 'skip-no-change' } and don't touch head.
31
+ if (!lastWrittenHlc) {
32
+ emit({
33
+ type: 'trace:head',
34
+ op: 'skip-no-change',
35
+ reason: 'flush',
36
+ path: p.changesHead,
37
+ nextHlc: null,
38
+ });
39
+ if (isPrimary) {
40
+ await upsertDeviceMetadata(adapter, remotePath, deviceId);
41
+ }
42
+ return;
43
+ }
23
44
  const readHeadIfExists = async () => {
24
45
  try {
25
46
  const data = await adapter.readFile(p.changesHead);
@@ -30,12 +51,48 @@ export async function flushToAdapter(adapter, remotePath, entries, isPrimary, co
30
51
  }
31
52
  };
32
53
  const priorHead = await readHeadIfExists();
33
- const bestHlc = (priorHead?.latestHlc && hlcCompareStr(priorHead.latestHlc, lastWrittenHlc) > 0)
34
- ? priorHead.latestHlc
35
- : lastWrittenHlc;
36
- await adapter.writeFile(p.changesHead, textEncoder.encode(JSON.stringify({ latestHlc: bestHlc }, null, 2)));
54
+ const priorHlc = priorHead?.latestHlc ?? null;
55
+ emit({
56
+ type: 'trace:head',
57
+ op: 'read',
58
+ reason: 'flush',
59
+ path: p.changesHead,
60
+ priorHlc,
61
+ });
62
+ // Already-current short-circuit. Nothing to write — head already at or
63
+ // beyond what we just wrote (replica catching up, retried flush, etc.).
64
+ if (priorHlc && hlcCompareStr(priorHlc, lastWrittenHlc) >= 0) {
65
+ emit({
66
+ type: 'trace:head',
67
+ op: 'skip-no-change',
68
+ reason: 'flush',
69
+ path: p.changesHead,
70
+ priorHlc,
71
+ nextHlc: lastWrittenHlc,
72
+ });
73
+ if (isPrimary) {
74
+ await upsertDeviceMetadata(adapter, remotePath, deviceId);
75
+ }
76
+ return;
77
+ }
78
+ // Defensive: if priorHlc somehow > our lastWritten (shouldn't happen
79
+ // after the >= short-circuit) flag as regression and don't write.
80
+ const regressed = priorHlc !== null && hlcCompareStr(priorHlc, lastWrittenHlc) > 0;
81
+ const bestHlc = regressed ? priorHlc : lastWrittenHlc;
82
+ emit({
83
+ type: 'trace:head',
84
+ op: 'write',
85
+ reason: 'flush',
86
+ path: p.changesHead,
87
+ priorHlc,
88
+ nextHlc: bestHlc,
89
+ regressed,
90
+ });
91
+ if (!regressed) {
92
+ console.log('[interocitor:write] flush.head', { path: p.changesHead, deviceId, isPrimary, priorHlc, nextHlc: bestHlc });
93
+ await adapter.writeFile(p.changesHead, textEncoder.encode(JSON.stringify({ latestHlc: bestHlc }, null, 2)));
94
+ }
37
95
  if (isPrimary) {
38
96
  await upsertDeviceMetadata(adapter, remotePath, deviceId);
39
97
  }
40
98
  }
41
- //# sourceMappingURL=flush.js.map
package/dist/core/hlc.js CHANGED
@@ -73,4 +73,3 @@ export function hlcParse(s) {
73
73
  nodeId: s.slice(secondDash + 1),
74
74
  };
75
75
  }
76
- //# sourceMappingURL=hlc.js.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Structured ID generation and validation.
3
+ *
4
+ * Device IDs: UUIDv7 — timestamp + random, client-generated.
5
+ * Mesh IDs: UUIDv7 + HMAC tag — timestamp + random + MAC, worker-issued.
6
+ * Row IDs: UUID v4/v7 with optional prefix — client-generated.
7
+ */
8
+ /**
9
+ * Generate a UUIDv7: 48-bit ms timestamp + 74 bits random, RFC 9562.
10
+ * Sortable by creation time. Globally unique without coordination.
11
+ */
12
+ export declare function uuidv7(): string;
13
+ /** Generate a device ID. UUIDv7 — sortable, globally unique. */
14
+ export declare function createDeviceId(): string;
15
+ /** Validate a device ID (must be UUIDv7). */
16
+ export declare function isValidDeviceId(id: unknown): id is string;
17
+ /**
18
+ * Issue a mesh/team ID with embedded HMAC tag.
19
+ *
20
+ * Layout (opaque string):
21
+ * <uuidv7>.<tag>
22
+ *
23
+ * Where tag = base64url(HMAC-SHA256(secret, uuidv7))[0..10]
24
+ *
25
+ * Only workers with the secret can mint valid mesh IDs.
26
+ * Clients and workers validate with `isValidMeshId(id, secret)`.
27
+ *
28
+ * @param secret — HMAC key (CryptoKey or raw bytes). Workers hold this.
29
+ */
30
+ export declare function issueMeshId(secret: CryptoKey): Promise<string>;
31
+ /**
32
+ * Validate a mesh ID: format check + HMAC tag verification.
33
+ *
34
+ * @param id — the full mesh ID string (uuid.tag)
35
+ * @param secret — same HMAC key used to issue
36
+ */
37
+ export declare function isValidMeshId(id: unknown, secret: CryptoKey): Promise<boolean>;
38
+ /**
39
+ * Parse a mesh ID into its parts. Does NOT verify tag.
40
+ */
41
+ export declare function parseMeshId(id: string): {
42
+ uuid: string;
43
+ tag: string;
44
+ } | null;
45
+ /**
46
+ * Create a mesh HMAC secret key for use with issueMeshId / isValidMeshId.
47
+ */
48
+ export declare function createMeshSecret(): Promise<CryptoKey>;
49
+ //# sourceMappingURL=ids.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ids.d.ts","sourceRoot":"","sources":["../../src/core/ids.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH;;;GAGG;AACH,wBAAgB,MAAM,IAAI,MAAM,CAiC/B;AAID,gEAAgE;AAChE,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAID,6CAA6C;AAC7C,wBAAgB,eAAe,CAAC,EAAE,EAAE,OAAO,GAAG,EAAE,IAAI,MAAM,CAEzD;AAID;;;;;;;;;;;;GAYG;AACH,wBAAsB,WAAW,CAAC,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,CAIpE;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,CASpF;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAO5E;AAED;;GAEG;AACH,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,SAAS,CAAC,CAM3D"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Structured ID generation and validation.
3
+ *
4
+ * Device IDs: UUIDv7 — timestamp + random, client-generated.
5
+ * Mesh IDs: UUIDv7 + HMAC tag — timestamp + random + MAC, worker-issued.
6
+ * Row IDs: UUID v4/v7 with optional prefix — client-generated.
7
+ */
8
+ // ─── UUIDv7 ──────────────────────────────────────────────────────────
9
+ /**
10
+ * Generate a UUIDv7: 48-bit ms timestamp + 74 bits random, RFC 9562.
11
+ * Sortable by creation time. Globally unique without coordination.
12
+ */
13
+ export function uuidv7() {
14
+ const now = Date.now();
15
+ // 6 bytes timestamp (48-bit ms)
16
+ const tsBytes = new Uint8Array(6);
17
+ let ts = now;
18
+ for (let i = 5; i >= 0; i--) {
19
+ tsBytes[i] = ts & 0xff;
20
+ ts = Math.floor(ts / 256);
21
+ }
22
+ // 10 bytes random
23
+ const randBytes = crypto.getRandomValues(new Uint8Array(10));
24
+ // Assemble 16 bytes
25
+ const bytes = new Uint8Array(16);
26
+ bytes.set(tsBytes, 0);
27
+ bytes.set(randBytes, 6);
28
+ // Set version 7 (bits 48-51)
29
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
30
+ // Set variant 10xx (bits 64-65)
31
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
32
+ // Format as UUID string
33
+ const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
34
+ return [
35
+ hex.slice(0, 8),
36
+ hex.slice(8, 12),
37
+ hex.slice(12, 16),
38
+ hex.slice(16, 20),
39
+ hex.slice(20, 32),
40
+ ].join('-');
41
+ }
42
+ // ─── Device IDs ──────────────────────────────────────────────────────
43
+ /** Generate a device ID. UUIDv7 — sortable, globally unique. */
44
+ export function createDeviceId() {
45
+ return uuidv7();
46
+ }
47
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
48
+ /** Validate a device ID (must be UUIDv7). */
49
+ export function isValidDeviceId(id) {
50
+ return typeof id === 'string' && UUID_RE.test(id);
51
+ }
52
+ // ─── Mesh / Team IDs ─────────────────────────────────────────────────
53
+ /**
54
+ * Issue a mesh/team ID with embedded HMAC tag.
55
+ *
56
+ * Layout (opaque string):
57
+ * <uuidv7>.<tag>
58
+ *
59
+ * Where tag = base64url(HMAC-SHA256(secret, uuidv7))[0..10]
60
+ *
61
+ * Only workers with the secret can mint valid mesh IDs.
62
+ * Clients and workers validate with `isValidMeshId(id, secret)`.
63
+ *
64
+ * @param secret — HMAC key (CryptoKey or raw bytes). Workers hold this.
65
+ */
66
+ export async function issueMeshId(secret) {
67
+ const id = uuidv7();
68
+ const tag = await computeTag(id, secret);
69
+ return `${id}.${tag}`;
70
+ }
71
+ /**
72
+ * Validate a mesh ID: format check + HMAC tag verification.
73
+ *
74
+ * @param id — the full mesh ID string (uuid.tag)
75
+ * @param secret — same HMAC key used to issue
76
+ */
77
+ export async function isValidMeshId(id, secret) {
78
+ if (typeof id !== 'string')
79
+ return false;
80
+ const dot = id.lastIndexOf('.');
81
+ if (dot === -1)
82
+ return false;
83
+ const uuid = id.slice(0, dot);
84
+ const tag = id.slice(dot + 1);
85
+ if (!UUID_RE.test(uuid) || !tag)
86
+ return false;
87
+ const expected = await computeTag(uuid, secret);
88
+ return timingSafeEqual(tag, expected);
89
+ }
90
+ /**
91
+ * Parse a mesh ID into its parts. Does NOT verify tag.
92
+ */
93
+ export function parseMeshId(id) {
94
+ const dot = id.lastIndexOf('.');
95
+ if (dot === -1)
96
+ return null;
97
+ const uuid = id.slice(0, dot);
98
+ const tag = id.slice(dot + 1);
99
+ if (!UUID_RE.test(uuid) || !tag)
100
+ return null;
101
+ return { uuid, tag };
102
+ }
103
+ /**
104
+ * Create a mesh HMAC secret key for use with issueMeshId / isValidMeshId.
105
+ */
106
+ export async function createMeshSecret() {
107
+ return crypto.subtle.generateKey({ name: 'HMAC', hash: 'SHA-256' }, true, ['sign', 'verify']);
108
+ }
109
+ // ─── Internal helpers ────────────────────────────────────────────────
110
+ const encoder = new TextEncoder();
111
+ async function computeTag(data, secret) {
112
+ const sig = await crypto.subtle.sign('HMAC', secret, encoder.encode(data));
113
+ // Take first 8 bytes (64 bits) → base64url (11 chars)
114
+ const bytes = new Uint8Array(sig, 0, 8);
115
+ return base64url(bytes);
116
+ }
117
+ function base64url(bytes) {
118
+ let binary = '';
119
+ for (let i = 0; i < bytes.length; i++)
120
+ binary += String.fromCharCode(bytes[i]);
121
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
122
+ }
123
+ /** Constant-time string comparison to prevent timing attacks on tag. */
124
+ function timingSafeEqual(a, b) {
125
+ if (a.length !== b.length)
126
+ return false;
127
+ let result = 0;
128
+ for (let i = 0; i < a.length; i++) {
129
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
130
+ }
131
+ return result === 0;
132
+ }
@@ -14,12 +14,20 @@ export interface CloudPaths {
14
14
  changeFile: (fileName: string) => string;
15
15
  }
16
16
  export declare function paths(root: string): CloudPaths;
17
- export declare function log(level: 'debug' | 'info' | 'warn' | 'error', ...args: unknown[]): void;
17
+ declare const LOG_LEVELS: readonly ["debug", "info", "warn", "error"];
18
+ export type LogLevel = (typeof LOG_LEVELS)[number];
19
+ export declare function logAtLevel(currentLevel: LogLevel, level: LogLevel, ...args: unknown[]): void;
20
+ export declare function log(level: LogLevel, ...args: unknown[]): void;
21
+ export declare function normalizeLogLevel(level: string | null | undefined): LogLevel;
22
+ export { LOG_LEVELS };
23
+ /**
24
+ * Generate a prefixed ID for internal use (change entries, snapshots, etc).
25
+ * Uses UUIDv7 for sortability.
26
+ */
18
27
  export declare function generateId(prefix: string): string;
19
28
  export declare function getDeviceId(override?: string): string;
20
29
  export declare const textEncoder: TextEncoder;
21
30
  export declare const textDecoder: TextDecoder;
22
- export declare const ROW_META_KEYS: Set<string>;
23
31
  export declare function hexFromBytes(bytes: Uint8Array): string;
24
32
  export declare function computeContentHash(payload: unknown): Promise<string>;
25
33
  //# sourceMappingURL=internals.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"internals.d.ts","sourceRoot":"","sources":["../../src/core/internals.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,MAAM,WAAW,UAAU;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC7C,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;IACzC,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;CAC1C;AAED,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAY9C;AAMD,wBAAgB,GAAG,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAGxF;AAID,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAIjD;AAED,wBAAgB,WAAW,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CASrD;AAID,eAAO,MAAM,WAAW,aAAoB,CAAC;AAC7C,eAAO,MAAM,WAAW,aAAoB,CAAC;AAE7C,eAAO,MAAM,aAAa,aAA6E,CAAC;AAExG,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAEtD;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAI1E"}
1
+ {"version":3,"file":"internals.d.ts","sourceRoot":"","sources":["../../src/core/internals.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,MAAM,WAAW,UAAU;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC7C,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;IACzC,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;CAC1C;AAED,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAY9C;AAKD,QAAA,MAAM,UAAU,6CAA8C,CAAC;AAE/D,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AAMnD,wBAAgB,UAAU,CAAC,YAAY,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAI5F;AAED,wBAAgB,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,GAAG,IAAI,CAE7D;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,QAAQ,CAE5E;AAED,OAAO,EAAE,UAAU,EAAE,CAAC;AAOtB;;;GAGG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED,wBAAgB,WAAW,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAUrD;AAID,eAAO,MAAM,WAAW,aAAoB,CAAC;AAC7C,eAAO,MAAM,WAAW,aAAoB,CAAC;AAE7C,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAEtD;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAI1E"}