@interocitor/core 0.0.0-beta.10

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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +706 -0
  3. package/dist/adapters/cloudflare.d.ts +78 -0
  4. package/dist/adapters/cloudflare.d.ts.map +1 -0
  5. package/dist/adapters/cloudflare.js +325 -0
  6. package/dist/adapters/google-drive.d.ts +64 -0
  7. package/dist/adapters/google-drive.d.ts.map +1 -0
  8. package/dist/adapters/google-drive.js +339 -0
  9. package/dist/adapters/memory.d.ts +53 -0
  10. package/dist/adapters/memory.d.ts.map +1 -0
  11. package/dist/adapters/memory.js +182 -0
  12. package/dist/adapters/webdav.d.ts +70 -0
  13. package/dist/adapters/webdav.d.ts.map +1 -0
  14. package/dist/adapters/webdav.js +323 -0
  15. package/dist/core/codec.d.ts +20 -0
  16. package/dist/core/codec.d.ts.map +1 -0
  17. package/dist/core/codec.js +102 -0
  18. package/dist/core/compaction.d.ts +45 -0
  19. package/dist/core/compaction.d.ts.map +1 -0
  20. package/dist/core/compaction.js +190 -0
  21. package/dist/core/connected-stores.d.ts +77 -0
  22. package/dist/core/connected-stores.d.ts.map +1 -0
  23. package/dist/core/connected-stores.js +76 -0
  24. package/dist/core/crdt.d.ts +36 -0
  25. package/dist/core/crdt.d.ts.map +1 -0
  26. package/dist/core/crdt.js +174 -0
  27. package/dist/core/errors.d.ts +47 -0
  28. package/dist/core/errors.d.ts.map +1 -0
  29. package/dist/core/errors.js +61 -0
  30. package/dist/core/flush.d.ts +9 -0
  31. package/dist/core/flush.d.ts.map +1 -0
  32. package/dist/core/flush.js +98 -0
  33. package/dist/core/hlc.d.ts +25 -0
  34. package/dist/core/hlc.d.ts.map +1 -0
  35. package/dist/core/hlc.js +75 -0
  36. package/dist/core/ids.d.ts +49 -0
  37. package/dist/core/ids.d.ts.map +1 -0
  38. package/dist/core/ids.js +132 -0
  39. package/dist/core/internals.d.ts +33 -0
  40. package/dist/core/internals.d.ts.map +1 -0
  41. package/dist/core/internals.js +72 -0
  42. package/dist/core/manifest.d.ts +56 -0
  43. package/dist/core/manifest.d.ts.map +1 -0
  44. package/dist/core/manifest.js +203 -0
  45. package/dist/core/pull.d.ts +26 -0
  46. package/dist/core/pull.d.ts.map +1 -0
  47. package/dist/core/pull.js +113 -0
  48. package/dist/core/row-id.d.ts +12 -0
  49. package/dist/core/row-id.d.ts.map +1 -0
  50. package/dist/core/row-id.js +11 -0
  51. package/dist/core/schema-types.d.ts +26 -0
  52. package/dist/core/schema-types.d.ts.map +1 -0
  53. package/dist/core/schema-types.js +31 -0
  54. package/dist/core/schema-types.type-test.d.ts +2 -0
  55. package/dist/core/schema-types.type-test.d.ts.map +1 -0
  56. package/dist/core/schema-types.type-test.js +224 -0
  57. package/dist/core/sync-engine.d.ts +364 -0
  58. package/dist/core/sync-engine.d.ts.map +1 -0
  59. package/dist/core/sync-engine.js +2475 -0
  60. package/dist/core/table.d.ts +260 -0
  61. package/dist/core/table.d.ts.map +1 -0
  62. package/dist/core/table.js +461 -0
  63. package/dist/core/types.d.ts +952 -0
  64. package/dist/core/types.d.ts.map +1 -0
  65. package/dist/core/types.js +6 -0
  66. package/dist/crypto/encryption.d.ts +61 -0
  67. package/dist/crypto/encryption.d.ts.map +1 -0
  68. package/dist/crypto/encryption.js +216 -0
  69. package/dist/crypto/keys.d.ts +48 -0
  70. package/dist/crypto/keys.d.ts.map +1 -0
  71. package/dist/crypto/keys.js +54 -0
  72. package/dist/handshake/channel.d.ts +117 -0
  73. package/dist/handshake/channel.d.ts.map +1 -0
  74. package/dist/handshake/channel.js +245 -0
  75. package/dist/handshake/index.d.ts +216 -0
  76. package/dist/handshake/index.d.ts.map +1 -0
  77. package/dist/handshake/index.js +199 -0
  78. package/dist/handshake/qr-public.d.ts +3 -0
  79. package/dist/handshake/qr-public.d.ts.map +1 -0
  80. package/dist/handshake/qr-public.js +1 -0
  81. package/dist/handshake/qr.d.ts +100 -0
  82. package/dist/handshake/qr.d.ts.map +1 -0
  83. package/dist/handshake/qr.js +102 -0
  84. package/dist/index.d.ts +50 -0
  85. package/dist/index.d.ts.map +1 -0
  86. package/dist/index.js +50 -0
  87. package/dist/storage/credential-store.d.ts +122 -0
  88. package/dist/storage/credential-store.d.ts.map +1 -0
  89. package/dist/storage/credential-store.js +356 -0
  90. package/dist/storage/local-store.d.ts +64 -0
  91. package/dist/storage/local-store.d.ts.map +1 -0
  92. package/dist/storage/local-store.js +490 -0
  93. package/dist/storage/reset.d.ts +10 -0
  94. package/dist/storage/reset.d.ts.map +1 -0
  95. package/dist/storage/reset.js +18 -0
  96. package/package.json +76 -0
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Compaction — snapshot + manifest rotation + change file pruning.
3
+ *
4
+ * Concurrency note:
5
+ * - No built-in lock or CAS on manifest pointer.
6
+ * - Concurrent compaction can race and overwrite the pointer.
7
+ * - Recommended: acquire a remote lease (e.g. mainline/compact-lock.json)
8
+ * and abort if another compactor is active, or if manifest generation
9
+ * changes after the lease is acquired.
10
+ *
11
+ * Extracted from Interocitor. Not part of the public API.
12
+ */
13
+ import { hlcSerialize, hlcCompareStr } from "./hlc.js";
14
+ import { paths, textEncoder, textDecoder, generateId, computeContentHash, log } from "./internals.js";
15
+ import { encodeSnapshotPayload, decodeSnapshotPayload } from "./codec.js";
16
+ import { readJsonIfExists, writeJson } from "./manifest.js";
17
+ import { hlcParse } from "./hlc.js";
18
+ async function computeGcFloor(ctx, nowMs) {
19
+ const p = paths(ctx.remotePath);
20
+ const graceMs = ctx.offlineGraceMs ?? 7 * 24 * 60 * 60000;
21
+ const cutoffMs = nowMs - graceMs;
22
+ const floors = [];
23
+ try {
24
+ const files = await ctx.adapter.listFiles(p.devicesFolder);
25
+ for (const file of files) {
26
+ if (!file.name.endsWith('.json'))
27
+ continue;
28
+ const deviceId = file.name.slice(0, -'.json'.length);
29
+ const meta = await readJsonIfExists(ctx.adapter, p.deviceFile(deviceId));
30
+ if (!meta || meta.retired)
31
+ continue;
32
+ const lastSeen = Date.parse(meta.lastSeenAt || '');
33
+ if (Number.isFinite(lastSeen) && lastSeen < cutoffMs)
34
+ continue;
35
+ // Active devices that have not yet acknowledged a watermark block
36
+ // advancement. They are still inside the offline grace window.
37
+ if (!meta.observedWatermarkHlc)
38
+ return ctx.manifest.gcFloorHlc ?? '';
39
+ floors.push(meta.observedWatermarkHlc);
40
+ }
41
+ }
42
+ catch {
43
+ return ctx.manifest.gcFloorHlc ?? '';
44
+ }
45
+ if (floors.length === 0)
46
+ return ctx.manifest.gcFloorHlc ?? '';
47
+ floors.sort(hlcCompareStr);
48
+ const candidate = floors[0];
49
+ const existing = ctx.manifest.gcFloorHlc ?? '';
50
+ if (existing && hlcCompareStr(existing, candidate) > 0)
51
+ return existing;
52
+ return candidate;
53
+ }
54
+ export async function compact(ctx) {
55
+ const { adapter, local, remotePath, manifest, codecState, deviceId, serverId } = ctx;
56
+ if (manifest.server.managed && deviceId !== serverId) {
57
+ throw new Error('Compaction is allowed only for the authorized server writer');
58
+ }
59
+ // Ensure the compactor has merged latest remote changes before snapshotting.
60
+ await ctx.pull();
61
+ const p = paths(remotePath);
62
+ const nowDate = new Date();
63
+ const now = nowDate.toISOString();
64
+ const gcFloorHlc = await computeGcFloor(ctx, nowDate.getTime());
65
+ const nextEpoch = manifest.epoch + 1;
66
+ const nextGeneration = manifest.generation + 1;
67
+ const snapshotPath = `${p.mainlineFolder}/snapshot-${nextEpoch}-${serverId}.json`;
68
+ // Build a full snapshot from IDB — the in-memory cache is partial.
69
+ const allRows = await local.getAllRows();
70
+ const snapshotTables = {};
71
+ for (const row of allRows) {
72
+ if (gcFloorHlc
73
+ && row._meta.deleted
74
+ && row._meta.deletedHlc
75
+ && hlcCompareStr(row._meta.deletedHlc, gcFloorHlc) <= 0) {
76
+ continue;
77
+ }
78
+ const t = row._meta.table;
79
+ if (!snapshotTables[t])
80
+ snapshotTables[t] = {};
81
+ snapshotTables[t][row._meta.rowId] = row;
82
+ }
83
+ const snapshot = {
84
+ snapshotId: generateId('snap'),
85
+ timestamp: now,
86
+ hlc: hlcSerialize(ctx.hlc),
87
+ epoch: nextEpoch,
88
+ schemaVersion: manifest.schema,
89
+ tables: snapshotTables,
90
+ };
91
+ const snapshotPayload = await encodeSnapshotPayload(codecState, snapshot);
92
+ await adapter.writeFile(snapshotPath, textEncoder.encode(snapshotPayload));
93
+ const manifestPayload = {
94
+ generation: nextGeneration,
95
+ parentGeneration: manifest.generation,
96
+ writtenBy: serverId,
97
+ writtenAt: now,
98
+ version: 3,
99
+ meshId: manifest.meshId,
100
+ schema: manifest.schema,
101
+ encrypted: manifest.encrypted,
102
+ server: manifest.server,
103
+ createdAt: manifest.createdAt,
104
+ epoch: nextEpoch,
105
+ watermarkHlc: hlcSerialize(ctx.hlc),
106
+ snapshotPath,
107
+ deltaPath: null,
108
+ gcFloorHlc,
109
+ gcEpoch: gcFloorHlc ? nextEpoch : manifest.gcEpoch,
110
+ gcCreatedAt: gcFloorHlc ? now : manifest.gcCreatedAt,
111
+ offlineGraceMs: ctx.offlineGraceMs,
112
+ };
113
+ const nextManifest = {
114
+ ...manifestPayload,
115
+ contentHash: await computeContentHash(manifestPayload),
116
+ };
117
+ const manifestFile = `manifest-${nextGeneration}.json`;
118
+ await writeJson(adapter, p.manifestFile(nextGeneration), nextManifest);
119
+ await writeJson(adapter, p.manifestPointer, {
120
+ currentGeneration: nextGeneration,
121
+ file: manifestFile,
122
+ });
123
+ await local.setMeta('epoch', nextEpoch);
124
+ // Prune all change files captured in the snapshot.
125
+ const watermarkHlc = nextManifest.watermarkHlc;
126
+ log('debug', 'compact() — pruning change files ≤ watermark', { watermarkHlc });
127
+ try {
128
+ const files = await adapter.listFiles(p.changesFolder);
129
+ for (const file of files) {
130
+ if (file.name === 'head.json')
131
+ continue;
132
+ const chgIdx = file.name.lastIndexOf('-chg_');
133
+ if (chgIdx === -1)
134
+ continue;
135
+ const fileHlc = file.name.slice(0, chgIdx);
136
+ if (hlcCompareStr(fileHlc, watermarkHlc) <= 0) {
137
+ await adapter.deleteFile(file.path);
138
+ }
139
+ }
140
+ log('debug', 'compact() — pruning complete');
141
+ }
142
+ catch (err) {
143
+ log('warn', 'compact() — pruning failed (non-fatal, snapshot is still valid)', err);
144
+ }
145
+ return nextManifest;
146
+ }
147
+ /** Returns the updated HLC after rehydration. */
148
+ export async function rehydrate(ctx) {
149
+ let hlc = ctx.hlc;
150
+ ctx.emit({ type: 'rehydrate:start' });
151
+ const snapshotPath = ctx.manifest?.snapshotPath;
152
+ if (!snapshotPath) {
153
+ ctx.emit({ type: 'rehydrate:complete', rowCount: 0 });
154
+ await ctx.pull();
155
+ return hlc;
156
+ }
157
+ try {
158
+ const data = await ctx.adapter.readFile(snapshotPath);
159
+ const snapshot = await decodeSnapshotPayload(ctx.codecState, ctx.local, textDecoder.decode(data), snapshotPath);
160
+ // Clear local state
161
+ await ctx.local.clearAll();
162
+ ctx.tables = {};
163
+ ctx.knownTables.clear();
164
+ // Write snapshot rows to IDB
165
+ let rowCount = 0;
166
+ for (const [tableName, rows] of Object.entries(snapshot.tables)) {
167
+ ctx.knownTables.add(tableName);
168
+ for (const row of Object.values(rows)) {
169
+ await ctx.local.putRow(row);
170
+ rowCount++;
171
+ }
172
+ }
173
+ // Restore HLC
174
+ if (snapshot.hlc) {
175
+ hlc = hlcParse(snapshot.hlc);
176
+ hlc.nodeId = ctx.deviceId;
177
+ }
178
+ await ctx.local.setMeta('epoch', snapshot.epoch);
179
+ ctx.emit({ type: 'rehydrate:complete', rowCount });
180
+ }
181
+ catch (err) {
182
+ ctx.emit({ type: 'decode:error', error: err instanceof Error ? err : new Error(String(err)), path: snapshotPath, context: { stage: 'rehydrate' } });
183
+ const poisoned = await ctx.poisonRemote(err, snapshotPath);
184
+ ctx.emit({ type: 'sync:error', error: poisoned });
185
+ throw poisoned;
186
+ }
187
+ // Pull any changes since the snapshot
188
+ await ctx.pull();
189
+ return hlc;
190
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Connected Stores — credentials for derived sub-stores of this Interocitor.
3
+ *
4
+ * Scope: this module only **stores** credentials for other Interocitor
5
+ * stores that this one owns conceptually (sub-stores). It does NOT
6
+ * construct child engines, run them, pair them, or know about adapters.
7
+ * Apps read credentials and build whatever Interocitor they want.
8
+ *
9
+ * Persistence: a single JSON list under a dedicated meta key in the parent
10
+ * LocalStore. No rows, no tables — sub-store credentials never appear in
11
+ * the parent's data model.
12
+ *
13
+ * Direction: one-way by construction. The parent stores credentials for
14
+ * sub-stores; sub-stores have no awareness of the parent.
15
+ *
16
+ * Security stance: anyone with read access to the parent inherits read
17
+ * access to every sub-store's credentials. That is the intended model
18
+ * for derived sub-stores.
19
+ */
20
+ import type { LocalStoreAdapter } from './types.ts';
21
+ /** Adapter pointer. Opaque to the engine; apps interpret `kind` and `config`. */
22
+ export interface ConnectedStoreAdapterRef {
23
+ kind: string;
24
+ config?: string;
25
+ }
26
+ /**
27
+ * Credentials for a sub-store. Just enough for an app to construct the
28
+ * corresponding child Interocitor; the engine never reads these fields.
29
+ */
30
+ export interface ConnectedStoreCredentials {
31
+ /** Stable id for the sub-store. Required and unique within parent. */
32
+ id: string;
33
+ /** Optional human-readable label. */
34
+ alias?: string;
35
+ /** Remote folder path inside the adapter for the sub-store. */
36
+ remotePath: string;
37
+ /** Sub-store passphrase, or null for unencrypted sub-stores. */
38
+ passphrase: string | null;
39
+ /** Whether the sub-store uses encryption. */
40
+ encrypted: boolean;
41
+ /** Suggested local IndexedDB name for the sub-store. */
42
+ dbName: string;
43
+ /** Optional adapter pointer the app can use to materialize a StorageAdapter. */
44
+ adapter?: ConnectedStoreAdapterRef;
45
+ /** Optional human-readable app name. */
46
+ appName?: string;
47
+ /** Free-form metadata for app UI. */
48
+ metadata?: Record<string, unknown>;
49
+ /** ISO timestamp the credentials were first stored. Set by the registry. */
50
+ createdAt?: string;
51
+ /** ISO timestamp of the most recent update. Set by the registry. */
52
+ updatedAt?: string;
53
+ }
54
+ /**
55
+ * Credential vault for connected (sub-)stores.
56
+ *
57
+ * No `connect()`, no factories, no adapter resolution. Apps read what they
58
+ * need and build their own engines.
59
+ */
60
+ export interface ConnectedStoresApi {
61
+ list(): Promise<ConnectedStoreCredentials[]>;
62
+ get(id: string): Promise<ConnectedStoreCredentials | null>;
63
+ put(credentials: ConnectedStoreCredentials): Promise<ConnectedStoreCredentials>;
64
+ remove(id: string): Promise<boolean>;
65
+ }
66
+ /** LocalStore-backed implementation. JSON list under a single meta key. */
67
+ export declare class LocalStoreConnectedStoresApi implements ConnectedStoresApi {
68
+ private readonly local;
69
+ constructor(local: LocalStoreAdapter);
70
+ private readAll;
71
+ private writeAll;
72
+ list(): Promise<ConnectedStoreCredentials[]>;
73
+ get(id: string): Promise<ConnectedStoreCredentials | null>;
74
+ put(credentials: ConnectedStoreCredentials): Promise<ConnectedStoreCredentials>;
75
+ remove(id: string): Promise<boolean>;
76
+ }
77
+ //# sourceMappingURL=connected-stores.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"connected-stores.d.ts","sourceRoot":"","sources":["../../src/core/connected-stores.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAIpD,iFAAiF;AACjF,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,MAAM,WAAW,yBAAyB;IACxC,sEAAsE;IACtE,EAAE,EAAE,MAAM,CAAC;IACX,qCAAqC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,UAAU,EAAE,MAAM,CAAC;IACnB,gEAAgE;IAChE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,6CAA6C;IAC7C,SAAS,EAAE,OAAO,CAAC;IACnB,wDAAwD;IACxD,MAAM,EAAE,MAAM,CAAC;IACf,gFAAgF;IAChF,OAAO,CAAC,EAAE,wBAAwB,CAAC;IACnC,wCAAwC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qCAAqC;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;GAKG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,IAAI,OAAO,CAAC,yBAAyB,EAAE,CAAC,CAAC;IAC7C,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,yBAAyB,GAAG,IAAI,CAAC,CAAC;IAC3D,GAAG,CAAC,WAAW,EAAE,yBAAyB,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAAC;IAChF,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACtC;AAED,2EAA2E;AAC3E,qBAAa,4BAA6B,YAAW,kBAAkB;IACzD,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,iBAAiB;YAEvC,OAAO;YAeP,QAAQ;IAIhB,IAAI,IAAI,OAAO,CAAC,yBAAyB,EAAE,CAAC;IAI5C,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,yBAAyB,GAAG,IAAI,CAAC;IAK1D,GAAG,CAAC,WAAW,EAAE,yBAAyB,GAAG,OAAO,CAAC,yBAAyB,CAAC;IAe/E,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAO3C"}
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Connected Stores — credentials for derived sub-stores of this Interocitor.
3
+ *
4
+ * Scope: this module only **stores** credentials for other Interocitor
5
+ * stores that this one owns conceptually (sub-stores). It does NOT
6
+ * construct child engines, run them, pair them, or know about adapters.
7
+ * Apps read credentials and build whatever Interocitor they want.
8
+ *
9
+ * Persistence: a single JSON list under a dedicated meta key in the parent
10
+ * LocalStore. No rows, no tables — sub-store credentials never appear in
11
+ * the parent's data model.
12
+ *
13
+ * Direction: one-way by construction. The parent stores credentials for
14
+ * sub-stores; sub-stores have no awareness of the parent.
15
+ *
16
+ * Security stance: anyone with read access to the parent inherits read
17
+ * access to every sub-store's credentials. That is the intended model
18
+ * for derived sub-stores.
19
+ */
20
+ const REGISTRY_META_KEY = 'interocitor:connected-stores';
21
+ /** LocalStore-backed implementation. JSON list under a single meta key. */
22
+ export class LocalStoreConnectedStoresApi {
23
+ constructor(local) {
24
+ this.local = local;
25
+ }
26
+ async readAll() {
27
+ const raw = await this.local.getMeta(REGISTRY_META_KEY);
28
+ if (!raw)
29
+ return [];
30
+ if (Array.isArray(raw))
31
+ return raw;
32
+ if (typeof raw === 'string') {
33
+ try {
34
+ const parsed = JSON.parse(raw);
35
+ return Array.isArray(parsed) ? parsed : [];
36
+ }
37
+ catch {
38
+ return [];
39
+ }
40
+ }
41
+ return [];
42
+ }
43
+ async writeAll(creds) {
44
+ await this.local.setMeta(REGISTRY_META_KEY, JSON.stringify(creds));
45
+ }
46
+ async list() {
47
+ return this.readAll();
48
+ }
49
+ async get(id) {
50
+ const all = await this.readAll();
51
+ return all.find(c => c.id === id) ?? null;
52
+ }
53
+ async put(credentials) {
54
+ if (!credentials.id)
55
+ throw new Error('ConnectedStoreCredentials.id is required');
56
+ const now = new Date().toISOString();
57
+ const all = await this.readAll();
58
+ const existing = all.find(c => c.id === credentials.id);
59
+ const stamped = {
60
+ ...credentials,
61
+ createdAt: existing?.createdAt ?? credentials.createdAt ?? now,
62
+ updatedAt: now,
63
+ };
64
+ const next = [...all.filter(c => c.id !== credentials.id), stamped];
65
+ await this.writeAll(next);
66
+ return stamped;
67
+ }
68
+ async remove(id) {
69
+ const all = await this.readAll();
70
+ const next = all.filter(c => c.id !== id);
71
+ if (next.length === all.length)
72
+ return false;
73
+ await this.writeAll(next);
74
+ return true;
75
+ }
76
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * CRDT Merge Engine — configurable per-column merge strategy.
3
+ *
4
+ * Each column in each row carries its own HLC.
5
+ * The merge strategy determines which value wins on conflict:
6
+ *
7
+ * - `'remote-wins'` — Remote always overwrites local. (Default)
8
+ * - `'lww'` — Last-Writer-Wins. Highest HLC wins.
9
+ * - `'local-wins'` — Keep local value when both exist.
10
+ * - custom function — `(local, remote, ctx) => ColumnEntry`
11
+ *
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.
17
+ */
18
+ import type { Row, Op, ChangeEntry, DatabaseSchemaDefinition } from './types.ts';
19
+ /**
20
+ * Apply a single op to the in-memory state.
21
+ * Returns the affected row (mutated in place) or null if no change.
22
+ */
23
+ export declare function applyOp(tables: Record<string, Record<string, Row>>, op: Op, schemaVersion: number, schema?: DatabaseSchemaDefinition): Row | null;
24
+ /**
25
+ * Apply a full change entry (potentially multiple ops).
26
+ * Returns list of affected rows.
27
+ */
28
+ export declare function applyChangeEntry(tables: Record<string, Record<string, Row>>, entry: ChangeEntry, schemaVersion: number, schema?: DatabaseSchemaDefinition): Row[];
29
+ /** Read a column value from a row, unwrapping the ColumnEntry. */
30
+ export declare function readColumn(row: Row, column: string): unknown;
31
+ /**
32
+ * Build a plain object from a row (strip HLC metadata).
33
+ * Returns user-facing fields with `_meta` projection (table, rowId, deleted).
34
+ */
35
+ export declare function rowToPlain(row: Row): Record<string, unknown>;
36
+ //# sourceMappingURL=crdt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crdt.d.ts","sourceRoot":"","sources":["../../src/core/crdt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EACV,GAAG,EACH,EAAE,EAEF,WAAW,EACX,wBAAwB,EAGzB,MAAM,YAAY,CAAC;AAoEpB;;;GAGG;AACH,wBAAgB,OAAO,CACrB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,EAC3C,EAAE,EAAE,EAAE,EACN,aAAa,EAAE,MAAM,EACrB,MAAM,CAAC,EAAE,wBAAwB,GAChC,GAAG,GAAG,IAAI,CAuEZ;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,EAC3C,KAAK,EAAE,WAAW,EAClB,aAAa,EAAE,MAAM,EACrB,MAAM,CAAC,EAAE,wBAAwB,GAChC,GAAG,EAAE,CAOP;AAED,kEAAkE;AAClE,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAG5D;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAU5D"}
@@ -0,0 +1,174 @@
1
+ /**
2
+ * CRDT Merge Engine — configurable per-column merge strategy.
3
+ *
4
+ * Each column in each row carries its own HLC.
5
+ * The merge strategy determines which value wins on conflict:
6
+ *
7
+ * - `'remote-wins'` — Remote always overwrites local. (Default)
8
+ * - `'lww'` — Last-Writer-Wins. Highest HLC wins.
9
+ * - `'local-wins'` — Keep local value when both exist.
10
+ * - custom function — `(local, remote, ctx) => ColumnEntry`
11
+ *
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.
17
+ */
18
+ import { hlcCompareStr } from "./hlc.js";
19
+ /**
20
+ * Resolve the merge strategy for a specific column.
21
+ *
22
+ * Resolution order (first defined wins):
23
+ * field-level → table-level → database-level → 'lww'
24
+ */
25
+ function resolveStrategy(schema, table, field) {
26
+ const tableDef = schema?.tables[table];
27
+ if (tableDef?.merge) {
28
+ const m = tableDef.merge;
29
+ if (typeof m === 'object' && 'fields' in m) {
30
+ const config = m;
31
+ if (config.fields?.[field])
32
+ return config.fields[field];
33
+ if (config.strategy)
34
+ return config.strategy;
35
+ }
36
+ else {
37
+ return m;
38
+ }
39
+ }
40
+ if (!schema)
41
+ return 'lww';
42
+ return schema.mergeStrategy ?? 'remote-wins';
43
+ }
44
+ /**
45
+ * Decide which column entry wins given a strategy.
46
+ *
47
+ * `local` may be undefined (new column). In that case, remote always wins
48
+ * regardless of strategy — there's no conflict.
49
+ */
50
+ function mergeColumn(local, remote, strategy, table, rowId, field) {
51
+ if (!local || !local.hlc)
52
+ return remote;
53
+ if (typeof strategy === 'function') {
54
+ const result = strategy(local, remote, { table, rowId, field });
55
+ return result.hlc !== local.hlc || result.value !== local.value ? result : null;
56
+ }
57
+ switch (strategy) {
58
+ case 'remote-wins':
59
+ return remote;
60
+ case 'local-wins':
61
+ return null;
62
+ default:
63
+ return hlcCompareStr(remote.hlc, local.hlc) > 0 ? remote : null;
64
+ }
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
+ /**
74
+ * Apply a single op to the in-memory state.
75
+ * Returns the affected row (mutated in place) or null if no change.
76
+ */
77
+ export function applyOp(tables, op, schemaVersion, schema) {
78
+ if (!tables[op.table]) {
79
+ tables[op.table] = {};
80
+ }
81
+ const table = tables[op.table];
82
+ if (op.type === 'delete') {
83
+ const existing = table[op.rowId];
84
+ if (existing) {
85
+ // Stale delete (older than current tombstone)?
86
+ if (existing._meta.deletedHlc && hlcCompareStr(op.hlc, existing._meta.deletedHlc) <= 0) {
87
+ return null;
88
+ }
89
+ // Any payload column newer than this delete? Then delete loses.
90
+ const hasNewerColumn = Object.values(existing.payload).some(entry => {
91
+ return entry?.hlc && hlcCompareStr(entry.hlc, op.hlc) > 0;
92
+ });
93
+ if (hasNewerColumn)
94
+ return null;
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 = {};
101
+ return existing;
102
+ }
103
+ // Tombstone for unseen row.
104
+ const row = blankRow(op.table, op.rowId, schemaVersion, true, op.hlc);
105
+ table[op.rowId] = row;
106
+ return row;
107
+ }
108
+ // Upsert.
109
+ let row = table[op.rowId];
110
+ let changed = false;
111
+ if (!row) {
112
+ row = blankRow(op.table, op.rowId, schemaVersion);
113
+ table[op.rowId] = row;
114
+ changed = true;
115
+ }
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];
133
+ const strategy = resolveStrategy(schema, op.table, col);
134
+ const winner = mergeColumn(existing, entry, strategy, op.table, op.rowId, col);
135
+ if (winner) {
136
+ row.payload[col] = winner;
137
+ changed = true;
138
+ }
139
+ }
140
+ return changed ? row : null;
141
+ }
142
+ /**
143
+ * Apply a full change entry (potentially multiple ops).
144
+ * Returns list of affected rows.
145
+ */
146
+ export function applyChangeEntry(tables, entry, schemaVersion, schema) {
147
+ const affected = [];
148
+ for (const op of entry.ops) {
149
+ const row = applyOp(tables, op, schemaVersion, schema);
150
+ if (row)
151
+ affected.push(row);
152
+ }
153
+ return affected;
154
+ }
155
+ /** Read a column value from a row, unwrapping the ColumnEntry. */
156
+ export function readColumn(row, column) {
157
+ const entry = row.payload?.[column];
158
+ return entry?.value;
159
+ }
160
+ /**
161
+ * Build a plain object from a row (strip HLC metadata).
162
+ * Returns user-facing fields with `_meta` projection (table, rowId, deleted).
163
+ */
164
+ export function rowToPlain(row) {
165
+ const result = {
166
+ _table: row._meta.table,
167
+ _rowId: row._meta.rowId,
168
+ _deleted: row._meta.deleted,
169
+ };
170
+ for (const [key, entry] of Object.entries(row.payload)) {
171
+ result[key] = entry.value;
172
+ }
173
+ return result;
174
+ }
@@ -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
+ }