memorix 1.8.8 → 1.9.0

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 (66) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +15 -1
  3. package/README.zh-CN.md +15 -1
  4. package/dist/cli/index.js +3722 -1599
  5. package/dist/cli/memcode.js +0 -0
  6. package/dist/index.js +634 -363
  7. package/dist/maintenance-runner.js +285 -103
  8. package/dist/media-audio-runner.js +285 -103
  9. package/dist/media-video-runner.js +285 -103
  10. package/dist/memcode-runtime/CHANGELOG.md +36 -0
  11. package/dist/sdk.js +627 -356
  12. package/dist/vector-backfill-runner.d.ts +4 -1
  13. package/dist/vector-backfill-runner.js +288 -104
  14. package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -2
  15. package/docs/API_REFERENCE.md +14 -0
  16. package/docs/CONFIGURATION.md +46 -0
  17. package/docs/DEVELOPMENT.md +2 -2
  18. package/docs/INTEGRATIONS.md +1 -0
  19. package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +344 -0
  20. package/docs/PERFORMANCE.md +32 -0
  21. package/docs/README.md +4 -2
  22. package/docs/hooks-architecture.md +18 -13
  23. package/llms-full.txt +4 -1
  24. package/llms.txt +3 -1
  25. package/package.json +2 -1
  26. package/plugins/antigravity/memorix/plugin.json +1 -1
  27. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  28. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  29. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  30. package/plugins/copilot/memorix/plugin.json +1 -1
  31. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  32. package/plugins/hermes/memorix/plugin.yaml +1 -1
  33. package/plugins/omp/memorix/package.json +1 -1
  34. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  35. package/plugins/pi/memorix/package.json +1 -1
  36. package/server.json +2 -2
  37. package/src/cli/command-guide.ts +9 -1
  38. package/src/cli/commands/agent-integrations.ts +2 -1
  39. package/src/cli/commands/hooks-install.ts +2 -0
  40. package/src/cli/commands/integrate.ts +2 -0
  41. package/src/cli/commands/serve-http.ts +11 -0
  42. package/src/cli/commands/setup.ts +11 -4
  43. package/src/cli/commands/sync-store.ts +191 -0
  44. package/src/cli/commands/sync.ts +8 -0
  45. package/src/hooks/handler.ts +6 -0
  46. package/src/hooks/installers/index.ts +181 -6
  47. package/src/hooks/normalizer.ts +60 -1
  48. package/src/hooks/types.ts +3 -1
  49. package/src/integrations/registry.ts +9 -0
  50. package/src/memory/observations.ts +15 -2
  51. package/src/runtime/maintenance-targets.ts +6 -0
  52. package/src/runtime/vector-backfill-runner.ts +185 -7
  53. package/src/store/orama-store.ts +85 -102
  54. package/src/sync/adapters/fs.ts +179 -0
  55. package/src/sync/adapters/github.ts +258 -0
  56. package/src/sync/adapters/object-store.ts +267 -0
  57. package/src/sync/adapters/postgres.ts +269 -0
  58. package/src/sync/engine.ts +353 -0
  59. package/src/sync/journal.ts +162 -0
  60. package/src/sync/merge.ts +116 -0
  61. package/src/sync/namespace.ts +15 -0
  62. package/src/sync/paging.ts +45 -0
  63. package/src/sync/policy.ts +37 -0
  64. package/src/sync/remote-factory.ts +49 -0
  65. package/src/sync/store-port.ts +288 -0
  66. package/src/sync/types.ts +183 -0
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Sync engine — orchestrates push and pull against any `SyncRemote`.
3
+ *
4
+ * Local-first and off the hot path: the engine reads the local store, ships a
5
+ * change journal, pulls remote batches, and applies them through the pure merge
6
+ * policy. All identity/versioning lives in `sync_row_state`; the engine only
7
+ * moves data between that state, the observation store, and the remote.
8
+ */
9
+ import type { Observation } from '../types.js';
10
+ import { computeChanges, contentHash, nowIso } from './journal.js';
11
+ import { compareVersion, mergeOne } from './merge.js';
12
+ import { eligibleObservations } from './policy.js';
13
+ import { isSafeSyncDeviceId } from './namespace.js';
14
+ import type {
15
+ ChangeBatch,
16
+ SyncConflict,
17
+ SyncDecision,
18
+ SyncCursor,
19
+ SyncPendingBatch,
20
+ SyncRemote,
21
+ SyncReport,
22
+ SyncRowState,
23
+ } from './types.js';
24
+ import { createHash } from 'node:crypto';
25
+
26
+ /**
27
+ * Everything the engine needs from the local side. The SQLite binding lives in
28
+ * `store-port.ts`; tests supply an in-memory fake.
29
+ */
30
+ export interface SyncStorePort {
31
+ projectId(): string;
32
+ namespace(): string;
33
+ /** Stable device id for this replica. */
34
+ deviceId(): string;
35
+ /** Refuse writes when this data directory was copied to another machine. */
36
+ assertCanPublish(): void;
37
+ /** Rotate a cloned installation onto a new device identity. */
38
+ rotateDevice(): string;
39
+ /** Current local cursor for this project/remote namespace. */
40
+ loadCursor(): Promise<SyncCursor>;
41
+ saveCursor(cursor: SyncCursor): Promise<void>;
42
+ /** Current project observations only. */
43
+ loadAll(): Promise<Observation[]>;
44
+ /** Recorded sync state, keyed by syncKey. */
45
+ loadState(): Promise<Map<string, SyncRowState>>;
46
+ /** Upsert sync-state rows. */
47
+ saveState(rows: SyncRowState[]): Promise<void>;
48
+ /** Reserve and return a fresh local observation id (for imports). */
49
+ allocateId(): Promise<number>;
50
+ /** Insert an imported observation and its sync state atomically. */
51
+ applyInsert(row: Observation, state: SyncRowState): Promise<void>;
52
+ /** Update an existing observation and its sync state atomically. */
53
+ applyUpdate(row: Observation, state: SyncRowState): Promise<void>;
54
+ /** Remove an observation and persist its tombstone state atomically. */
55
+ applyRemove(id: number, state: SyncRowState): Promise<void>;
56
+ getById(id: number): Promise<Observation | undefined>;
57
+ recordConflict(conflict: SyncConflict): Promise<void>;
58
+ loadPendingBatches(): Promise<SyncPendingBatch[]>;
59
+ enqueueBatch(pending: SyncPendingBatch): Promise<void>;
60
+ markBatchShipped(sequence: number): Promise<void>;
61
+ /** Next monotonic batch sequence for this device. */
62
+ nextSequence(): Promise<number>;
63
+ }
64
+
65
+ const PULL_PAGE_SIZE = 100;
66
+ const MAX_BATCH_BYTES = 750_000;
67
+
68
+ export interface RunOptions {
69
+ deviceId: string;
70
+ dryRun: boolean;
71
+ /** When false, only pull (no local changes shipped). */
72
+ push: boolean;
73
+ /** When false, only push (no remote changes applied). */
74
+ pull: boolean;
75
+ }
76
+
77
+ export async function runSync(
78
+ store: SyncStorePort,
79
+ remote: SyncRemote,
80
+ opts: RunOptions,
81
+ ): Promise<SyncReport> {
82
+ const report: SyncReport = {
83
+ remote: remote.kind,
84
+ namespace: remote.namespace,
85
+ projectId: store.projectId(),
86
+ deviceId: opts.deviceId,
87
+ pushed: 0,
88
+ pulledBatches: 0,
89
+ applied: 0,
90
+ skipped: 0,
91
+ tombstoned: 0,
92
+ eligible: 0,
93
+ excluded: 0,
94
+ conflicts: 0,
95
+ pending: 0,
96
+ dryRun: opts.dryRun,
97
+ decisions: [],
98
+ };
99
+
100
+ if (remote.namespace !== store.namespace()) {
101
+ throw new Error('[memorix] sync refused: store and remote namespaces do not match');
102
+ }
103
+ if (store.deviceId() !== opts.deviceId) {
104
+ throw new Error('[memorix] sync refused: requested device does not match the local replica identity');
105
+ }
106
+ try {
107
+ // Check the local replica before a remote init can create a branch, table,
108
+ // or bucket prefix. A copied data directory must not gain write access by
109
+ // merely starting a sync run.
110
+ if (opts.push && !opts.dryRun) store.assertCanPublish();
111
+ await remote.init({ create: opts.push && !opts.dryRun });
112
+
113
+ // ── Push ──────────────────────────────────────────────────────────
114
+ if (opts.push) {
115
+ const pending = await store.loadPendingBatches();
116
+ report.pending = pending.length;
117
+ if (!opts.dryRun) {
118
+ for (const pendingBatch of pending) {
119
+ validateBatches([pendingBatch.batch], store.projectId(), remote.namespace);
120
+ if (pendingBatch.batch.deviceId !== opts.deviceId) {
121
+ throw new Error('[memorix] sync outbox rejected: batch belongs to another device');
122
+ }
123
+ await remote.push(pendingBatch.batch);
124
+ await store.saveState(pendingBatch.states);
125
+ await store.markBatchShipped(pendingBatch.batch.sequence);
126
+ report.pushed += pendingBatch.batch.entries.length;
127
+ }
128
+ report.pending = 0;
129
+ }
130
+ const current = await store.loadAll();
131
+ const filtered = eligibleObservations(current, store.projectId());
132
+ report.eligible = filtered.eligible.length;
133
+ report.excluded = filtered.excluded;
134
+ const state = await store.loadState();
135
+ const changes = computeChanges({
136
+ current: filtered.eligible,
137
+ state,
138
+ deviceId: opts.deviceId,
139
+ projectId: store.projectId(),
140
+ });
141
+ if (changes.length > 0) {
142
+ const chunks = chunkChanges(changes);
143
+ for (const chunk of chunks) {
144
+ const sequence = opts.dryRun ? 0 : await store.nextSequence();
145
+ const batch: ChangeBatch = {
146
+ formatVersion: 3,
147
+ namespace: remote.namespace,
148
+ projectId: store.projectId(),
149
+ deviceId: opts.deviceId,
150
+ sequence,
151
+ producedAt: nowIso(),
152
+ entries: chunk.map((c) => c.entry),
153
+ };
154
+ if (!opts.dryRun) validateBatches([batch], store.projectId(), remote.namespace);
155
+ report.pushed += chunk.length;
156
+ if (!opts.dryRun) {
157
+ const states = chunk.map((c) => ({ ...c.nextState, shippedSeq: sequence }));
158
+ await store.enqueueBatch({ batch, states });
159
+ await remote.push(batch);
160
+ await store.saveState(states);
161
+ await store.markBatchShipped(sequence);
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ // ── Pull ──────────────────────────────────────────────────────────
168
+ if (opts.pull) {
169
+ const cursor = await store.loadCursor();
170
+ const state = await store.loadState();
171
+ const pullSince = { ...cursor.applied };
172
+ let pageToken: string | undefined;
173
+ let hasMore = true;
174
+ while (hasMore) {
175
+ // Keep the baseline fixed for every page. Adapters use pageToken to
176
+ // advance through one logical pull; passing the mutating cursor here
177
+ // can skip later rows after page one.
178
+ const page = await remote.pull(pullSince, PULL_PAGE_SIZE, pageToken);
179
+ report.pulledBatches += page.batches.length;
180
+ validateBatches(page.batches, store.projectId(), remote.namespace);
181
+
182
+ for (const batch of page.batches) {
183
+ if (batch.deviceId === opts.deviceId) {
184
+ cursor.applied[batch.deviceId] = Math.max(cursor.applied[batch.deviceId] ?? 0, batch.sequence);
185
+ continue;
186
+ }
187
+ for (const entry of batch.entries) {
188
+ const local = state.get(entry.syncKey);
189
+ const incomingHash = entry.row ? contentHash(entry.row) : '';
190
+ if (entry.contentHash !== incomingHash) {
191
+ throw new Error('[memorix] sync batch rejected: content hash mismatch');
192
+ }
193
+ if (
194
+ local
195
+ && compareVersion(entry.version, { revision: local.revision, writer: local.writer }) === 0
196
+ && local.contentHash !== incomingHash
197
+ ) {
198
+ throw new Error('[memorix] sync batch rejected: same version has different content');
199
+ }
200
+ const { decision, action } = mergeOne({ incoming: entry, local, incomingHash });
201
+ recordDecision(report, decision);
202
+ if (local && entry.version.revision === local.revision && entry.version.writer !== local.writer) {
203
+ const currentRow = local.obsId == null ? undefined : await store.getById(local.obsId);
204
+ if (opts.dryRun) report.conflicts++;
205
+ else await recordConcurrentConflict(store, report, local, entry, action.type === 'noop' ? entry.row : currentRow);
206
+ }
207
+ if (opts.dryRun || action.type === 'noop') {
208
+ if (!opts.dryRun && action.type === 'noop' && decision.outcome === 'apply') {
209
+ const next: SyncRowState = {
210
+ projectId: store.projectId(),
211
+ syncKey: entry.syncKey,
212
+ obsId: null,
213
+ revision: entry.version.revision,
214
+ writer: entry.version.writer,
215
+ kind: 'tombstone',
216
+ contentHash: '',
217
+ shippedSeq: local?.shippedSeq ?? 0,
218
+ };
219
+ state.set(entry.syncKey, next);
220
+ await store.saveState([next]);
221
+ }
222
+ continue;
223
+ }
224
+
225
+ let next: SyncRowState;
226
+ if (action.type === 'insert') {
227
+ const newId = await store.allocateId();
228
+ const row = { ...entry.row!, id: newId };
229
+ next = { projectId: store.projectId(), syncKey: entry.syncKey, obsId: newId, revision: action.version.revision, writer: action.version.writer, kind: 'upsert', contentHash: action.contentHash, shippedSeq: local?.shippedSeq ?? 0 };
230
+ await store.applyInsert(row, next);
231
+ } else if (action.type === 'update') {
232
+ const row = { ...entry.row!, id: action.obsId };
233
+ next = { projectId: store.projectId(), syncKey: entry.syncKey, obsId: action.obsId, revision: action.version.revision, writer: action.version.writer, kind: 'upsert', contentHash: action.contentHash, shippedSeq: local?.shippedSeq ?? 0 };
234
+ await store.applyUpdate(row, next);
235
+ } else {
236
+ next = { projectId: store.projectId(), syncKey: entry.syncKey, obsId: null, revision: action.version.revision, writer: action.version.writer, kind: 'tombstone', contentHash: '', shippedSeq: local?.shippedSeq ?? 0 };
237
+ await store.applyRemove(action.obsId, next);
238
+ }
239
+ state.set(entry.syncKey, next);
240
+ await store.saveState([next]);
241
+ }
242
+ cursor.applied[batch.deviceId] = Math.max(cursor.applied[batch.deviceId] ?? 0, batch.sequence);
243
+ }
244
+ hasMore = page.hasMore;
245
+ pageToken = page.nextPageToken;
246
+ if (!opts.dryRun && page.batches.length > 0) await store.saveCursor(cursor);
247
+ if (page.batches.length === 0 && !page.nextPageToken) hasMore = false;
248
+ if (hasMore && !page.nextPageToken) throw new Error('[memorix] sync remote returned an unpaged continuation');
249
+ }
250
+ }
251
+ return report;
252
+ } finally {
253
+ await remote.close();
254
+ }
255
+ }
256
+
257
+ function validateBatches(batches: ChangeBatch[], projectId: string, namespace: string): void {
258
+ for (const batch of batches) {
259
+ if (batch.formatVersion !== 3 || batch.projectId !== projectId || batch.namespace !== namespace) {
260
+ throw new Error('[memorix] sync batch rejected: wrong format, project, or remote namespace');
261
+ }
262
+ if (
263
+ !isSafeSyncDeviceId(batch.deviceId)
264
+ || !Number.isSafeInteger(batch.sequence)
265
+ || batch.sequence < 1
266
+ || !Array.isArray(batch.entries)
267
+ || Buffer.byteLength(JSON.stringify(batch), 'utf8') > MAX_BATCH_BYTES
268
+ ) {
269
+ throw new Error('[memorix] sync batch rejected: malformed envelope');
270
+ }
271
+ for (const entry of batch.entries) {
272
+ if (
273
+ !entry.syncKey
274
+ || (entry.kind !== 'upsert' && entry.kind !== 'tombstone')
275
+ || !entry.version
276
+ || !Number.isSafeInteger(entry.version.revision)
277
+ || entry.version.revision < 1
278
+ || !entry.version.writer
279
+ || typeof entry.contentHash !== 'string'
280
+ || (entry.kind === 'tombstone' && (entry.row !== undefined || entry.contentHash !== ''))
281
+ || (entry.kind === 'upsert' && (!/^[a-f0-9]{64}$/.test(entry.contentHash) || entry.row === undefined))
282
+ ) {
283
+ throw new Error('[memorix] sync batch rejected: malformed change entry');
284
+ }
285
+ if (
286
+ entry.kind === 'upsert'
287
+ && (!Number.isSafeInteger(entry.row!.id) || entry.row!.id < 1 || !eligibleObservations([entry.row!], projectId).eligible.length)
288
+ ) {
289
+ throw new Error('[memorix] sync batch rejected: ineligible observation payload');
290
+ }
291
+ const incomingHash = entry.row ? contentHash(entry.row) : '';
292
+ if (entry.contentHash !== incomingHash) {
293
+ throw new Error('[memorix] sync batch rejected: content hash mismatch');
294
+ }
295
+ }
296
+ }
297
+ }
298
+
299
+ function chunkChanges<T extends { entry: ChangeBatch['entries'][number] }>(changes: T[]): T[][] {
300
+ const chunks: T[][] = [];
301
+ let current: T[] = [];
302
+ let currentBytes = 2;
303
+ for (const change of changes) {
304
+ const bytes = Buffer.byteLength(JSON.stringify(change.entry), 'utf8') + 1;
305
+ if (bytes + 2 > MAX_BATCH_BYTES) throw new Error('[memorix] one sync event is too large for the relay');
306
+ if (current.length > 0 && currentBytes + bytes > MAX_BATCH_BYTES) {
307
+ chunks.push(current);
308
+ current = [];
309
+ currentBytes = 2;
310
+ }
311
+ current.push(change);
312
+ currentBytes += bytes;
313
+ }
314
+ if (current.length > 0) chunks.push(current);
315
+ return chunks;
316
+ }
317
+
318
+ async function recordConcurrentConflict(
319
+ store: SyncStorePort,
320
+ report: SyncReport,
321
+ local: SyncRowState,
322
+ incoming: ChangeBatch['entries'][number],
323
+ localRow: Observation | undefined,
324
+ ): Promise<void> {
325
+ const incomingWins = compareVersion(incoming.version, { revision: local.revision, writer: local.writer }) > 0;
326
+ const loser = incomingWins ? { revision: local.revision, writer: local.writer } : incoming.version;
327
+ const loserRow = incomingWins ? localRow : incoming.row;
328
+ const id = createHash('sha256')
329
+ .update(`${store.projectId()}|${incoming.syncKey}|${local.revision}|${local.writer}|${incoming.version.revision}|${incoming.version.writer}`)
330
+ .digest('hex');
331
+ await store.recordConflict({
332
+ id,
333
+ projectId: store.projectId(),
334
+ syncKey: incoming.syncKey,
335
+ winner: incomingWins ? incoming.version : { revision: local.revision, writer: local.writer },
336
+ loser,
337
+ loserKind: incomingWins ? local.kind : incoming.kind,
338
+ loserRow,
339
+ createdAt: nowIso(),
340
+ reason: 'concurrent versions shared the same logical revision',
341
+ });
342
+ report.conflicts++;
343
+ }
344
+
345
+ function recordDecision(report: SyncReport, decision: SyncDecision): void {
346
+ report.decisions.push(decision);
347
+ if (decision.outcome === 'apply') {
348
+ if (decision.kind === 'tombstone') report.tombstoned++;
349
+ else report.applied++;
350
+ } else {
351
+ report.skipped++;
352
+ }
353
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Change journal — derives the set of local changes to ship without diffing
3
+ * the whole database, and defines the stable cross-device row identity.
4
+ *
5
+ * `sync_row_state` maps `syncKey -> SyncRowState` (version + content hash +
6
+ * local id). A row is "dirty" when its current content hash differs from the
7
+ * recorded one; a key present in state but absent from `observations` is a
8
+ * tombstone. These tables are additive — they never touch the `observations`
9
+ * schema — so enabling sync needs no migration of existing data.
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+
13
+ import type { Observation } from '../types.js';
14
+ import { nextVersion } from './merge.js';
15
+ import type { ChangeEntry, SyncRowState } from './types.js';
16
+
17
+ export const CREATE_SYNC_TABLES = [
18
+ `CREATE TABLE IF NOT EXISTS sync_row_state (
19
+ projectId TEXT NOT NULL,
20
+ syncKey TEXT NOT NULL,
21
+ obsId INTEGER,
22
+ revision INTEGER NOT NULL,
23
+ writer TEXT NOT NULL,
24
+ kind TEXT NOT NULL,
25
+ contentHash TEXT NOT NULL,
26
+ shippedSeq INTEGER NOT NULL,
27
+ PRIMARY KEY (projectId, syncKey)
28
+ );`,
29
+ `CREATE TABLE IF NOT EXISTS sync_meta (
30
+ key TEXT PRIMARY KEY,
31
+ value TEXT NOT NULL
32
+ );`,
33
+ `CREATE TABLE IF NOT EXISTS sync_conflicts (
34
+ id TEXT PRIMARY KEY,
35
+ projectId TEXT NOT NULL,
36
+ syncKey TEXT NOT NULL,
37
+ winnerJson TEXT NOT NULL,
38
+ loserJson TEXT NOT NULL,
39
+ createdAt TEXT NOT NULL,
40
+ reason TEXT NOT NULL
41
+ );`,
42
+ `CREATE TABLE IF NOT EXISTS sync_outbox (
43
+ projectId TEXT NOT NULL,
44
+ namespace TEXT NOT NULL,
45
+ deviceId TEXT NOT NULL,
46
+ sequence INTEGER NOT NULL,
47
+ batchJson TEXT NOT NULL,
48
+ statesJson TEXT NOT NULL,
49
+ createdAt TEXT NOT NULL,
50
+ PRIMARY KEY (projectId, namespace, deviceId, sequence)
51
+ );`,
52
+ ].join('\n');
53
+
54
+ /** ISO timestamp helper (single source of "now" for the engine). */
55
+ export function nowIso(): string {
56
+ return new Date().toISOString();
57
+ }
58
+
59
+ /**
60
+ * Stable cross-device identity for a row.
61
+ * - Topic-keyed observations share `t:<projectId>:<topicKey>` on every
62
+ * machine, so an upsert to the same topic converges instead of forking.
63
+ * - Unkeyed observations get `u:<originDevice>:<originId>`, minted once at
64
+ * first ship and thereafter carried in `sync_row_state`. Two devices
65
+ * inserting unrelated rows can never collide, because the origin device id
66
+ * is part of the key.
67
+ */
68
+ export function deriveSyncKey(obs: Observation, originDevice: string): string {
69
+ const project = encodeURIComponent(obs.projectId);
70
+ if (obs.topicKey) return `p:${project}:t:${encodeURIComponent(obs.topicKey)}`;
71
+ return `p:${project}:u:${encodeURIComponent(originDevice)}:${obs.id}`;
72
+ }
73
+
74
+ /**
75
+ * Content fingerprint of the row body. Excludes replica-local fields (`id`,
76
+ * `writeGeneration`) so the same logical content hashes identically on every
77
+ * machine and does not spuriously mark a row dirty after import.
78
+ */
79
+ export function contentHash(obs: Observation): string {
80
+ const { id: _id, writeGeneration: _wg, ...rest } = obs as Observation & { writeGeneration?: number };
81
+ const stable = stableJson(rest);
82
+ return createHash('sha256').update(stable).digest('hex');
83
+ }
84
+
85
+ function stableJson(value: unknown): string {
86
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
87
+ if (value && typeof value === 'object') {
88
+ return `{${Object.keys(value as Record<string, unknown>).sort().map((key) =>
89
+ `${JSON.stringify(key)}:${stableJson((value as Record<string, unknown>)[key])}`).join(',')}}`;
90
+ }
91
+ return JSON.stringify(value) ?? 'null';
92
+ }
93
+
94
+ export interface ComputeInput {
95
+ /** All live observations currently in the local store. */
96
+ current: Observation[];
97
+ /** Recorded sync state, keyed by syncKey. */
98
+ state: Map<string, SyncRowState>;
99
+ /** This device's stable id (becomes the `writer` of new local versions). */
100
+ deviceId: string;
101
+ projectId: string;
102
+ }
103
+
104
+ /** A change plus the row-state it should persist once the batch is shipped. */
105
+ export interface ComputedChange {
106
+ entry: ChangeEntry;
107
+ nextState: SyncRowState;
108
+ }
109
+
110
+ /**
111
+ * Compute the local changes to ship: new/modified rows as upserts, and rows
112
+ * that vanished from `observations` (but are still live in state) as
113
+ * tombstones. Version numbers advance per-row from the recorded revision.
114
+ */
115
+ export function computeChanges(input: ComputeInput): ComputedChange[] {
116
+ const { current, state, deviceId, projectId } = input;
117
+ const at = nowIso();
118
+ const changes: ComputedChange[] = [];
119
+ const seenKeys = new Set<string>();
120
+
121
+ for (const obs of current) {
122
+ // Reuse the recorded key for this row if one exists (its origin device may
123
+ // differ from us); otherwise derive a fresh one.
124
+ const priorEntry = findStateByObsId(state, obs.id);
125
+ const syncKey = priorEntry?.syncKey ?? deriveSyncKey(obs, deviceId);
126
+ seenKeys.add(syncKey);
127
+
128
+ const hash = contentHash(obs);
129
+ const prior = state.get(syncKey);
130
+ if (prior && prior.kind === 'upsert' && prior.obsId === obs.id && prior.contentHash === hash) {
131
+ continue; // unchanged since last ship
132
+ }
133
+ const version = nextVersion(prior, deviceId, at);
134
+ changes.push({
135
+ entry: { syncKey, kind: 'upsert', version, contentHash: hash, row: obs },
136
+ nextState: { projectId, syncKey, obsId: obs.id, revision: version.revision, writer: deviceId, kind: 'upsert', contentHash: hash, shippedSeq: 0 },
137
+ });
138
+ }
139
+
140
+ // Tombstones also retract a row that was previously eligible but is now
141
+ // private, targeted, candidate, or ephemeral. Without this branch a user
142
+ // could make a memory private locally while it remained visible on another
143
+ // device forever. A row that was never recorded in state produces no event.
144
+ for (const prior of state.values()) {
145
+ if (prior.kind === 'tombstone') continue;
146
+ if (seenKeys.has(prior.syncKey)) continue;
147
+ const version = nextVersion(prior, deviceId, at);
148
+ changes.push({
149
+ entry: { syncKey: prior.syncKey, kind: 'tombstone', version, contentHash: '' },
150
+ nextState: { projectId, syncKey: prior.syncKey, obsId: null, revision: version.revision, writer: deviceId, kind: 'tombstone', contentHash: '', shippedSeq: 0 },
151
+ });
152
+ }
153
+
154
+ return changes;
155
+ }
156
+
157
+ function findStateByObsId(state: Map<string, SyncRowState>, obsId: number): SyncRowState | undefined {
158
+ for (const s of state.values()) {
159
+ if (s.kind === 'upsert' && s.obsId === obsId) return s;
160
+ }
161
+ return undefined;
162
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Merge policy — pure, convergent last-writer-wins.
3
+ *
4
+ * Every row is versioned by a per-row logical clock `(revision, writer)` and
5
+ * merged against the locally recorded `SyncRowState` for its `syncKey`. Because
6
+ * the comparison is a single strict total order, applying a set of changes in
7
+ * any order yields the same final state (CRDT-style convergence). Tombstones
8
+ * are ordinary versioned states, so a stale upsert can never resurrect a row a
9
+ * newer delete removed.
10
+ *
11
+ * Everything here is pure: no I/O, no store access. That keeps the policy
12
+ * unit-testable and identical across every remote backend.
13
+ */
14
+ import type { ChangeEntry, RowVersion, SyncDecision, SyncRowState } from './types.js';
15
+
16
+ /**
17
+ * Compare two row versions. Returns >0 if `a` wins, <0 if `b` wins, 0 if they
18
+ * are the same logical version. Lexicographic over `(revision, writer)`.
19
+ */
20
+ export function compareVersion(a: RowVersion, b: RowVersion): number {
21
+ if (a.revision !== b.revision) return a.revision - b.revision;
22
+ if (a.writer === b.writer) return 0;
23
+ return a.writer < b.writer ? -1 : 1;
24
+ }
25
+
26
+ /** The next logical version for a locally originated write. */
27
+ export function nextVersion(prior: SyncRowState | undefined, writer: string, at: string): RowVersion {
28
+ const revision = (prior?.revision ?? 0) + 1;
29
+ return { revision, writer, at };
30
+ }
31
+
32
+ export type MergeOutcome = SyncDecision['outcome'];
33
+
34
+ /** What the engine must do to the local store to realize the merge result. */
35
+ export type MergeAction =
36
+ | { type: 'noop' }
37
+ | { type: 'insert'; version: RowVersion; contentHash: string }
38
+ | { type: 'update'; obsId: number; version: RowVersion; contentHash: string }
39
+ | { type: 'remove'; obsId: number; version: RowVersion };
40
+
41
+ export interface MergeInput {
42
+ incoming: ChangeEntry;
43
+ /** Locally recorded state for this syncKey, or undefined if unknown here. */
44
+ local: SyncRowState | undefined;
45
+ /** Content fingerprint of `incoming.row` (upserts only). */
46
+ incomingHash: string;
47
+ }
48
+
49
+ export interface MergeResult {
50
+ decision: SyncDecision;
51
+ action: MergeAction;
52
+ }
53
+
54
+ /**
55
+ * Decide the fate of one incoming change against local state. Pure LWW: the
56
+ * strictly-newer version wins; ties (same version) are treated as already
57
+ * applied. No lifecycle special-casing — generation/revision ordering alone
58
+ * guarantees a stale write never overrides a newer one.
59
+ */
60
+ export function mergeOne(input: MergeInput): MergeResult {
61
+ const { incoming, local, incomingHash } = input;
62
+ const { syncKey, kind, version } = incoming;
63
+
64
+ // Nothing known locally: an upsert becomes an insert; a tombstone is still
65
+ // recorded (durably) so a later stale upsert cannot resurrect the row.
66
+ if (!local) {
67
+ if (kind === 'tombstone') {
68
+ return {
69
+ decision: { syncKey, kind, outcome: 'apply', reason: 'record remote tombstone' },
70
+ action: { type: 'noop' },
71
+ };
72
+ }
73
+ return {
74
+ decision: { syncKey, kind, outcome: 'apply', reason: 'new row from remote' },
75
+ action: { type: 'insert', version, contentHash: incomingHash },
76
+ };
77
+ }
78
+
79
+ const cmp = compareVersion(version, { revision: local.revision, writer: local.writer });
80
+ if (cmp < 0) {
81
+ return {
82
+ decision: { syncKey, kind, outcome: 'skip-older', reason: 'local version is newer' },
83
+ action: { type: 'noop' },
84
+ };
85
+ }
86
+ if (cmp === 0) {
87
+ return {
88
+ decision: { syncKey, kind, outcome: 'skip-equal', reason: 'identical version' },
89
+ action: { type: 'noop' },
90
+ };
91
+ }
92
+
93
+ // Incoming wins.
94
+ if (kind === 'tombstone') {
95
+ // Delete a row we still hold; if already tombstoned locally, just advance.
96
+ const action: MergeAction =
97
+ local.obsId != null ? { type: 'remove', obsId: local.obsId, version } : { type: 'noop' };
98
+ return {
99
+ decision: { syncKey, kind, outcome: 'apply', reason: 'newer tombstone' },
100
+ action,
101
+ };
102
+ }
103
+
104
+ // Upsert wins. Update in place if we still have a live row, else re-insert
105
+ // (covers reviving from a local tombstone with a genuinely newer version).
106
+ if (local.obsId != null) {
107
+ return {
108
+ decision: { syncKey, kind, outcome: 'apply', reason: 'newer upsert' },
109
+ action: { type: 'update', obsId: local.obsId, version, contentHash: incomingHash },
110
+ };
111
+ }
112
+ return {
113
+ decision: { syncKey, kind, outcome: 'apply', reason: 'newer upsert after local tombstone' },
114
+ action: { type: 'insert', version, contentHash: incomingHash },
115
+ };
116
+ }
@@ -0,0 +1,15 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /** Stable, path-safe namespace for one canonical Git project. */
4
+ export function syncNamespace(projectId: string): string {
5
+ return `project-${createHash('sha256').update(projectId).digest('hex').slice(0, 32)}`;
6
+ }
7
+
8
+ export function projectSyncPrefix(projectId: string): string {
9
+ return `p:${encodeURIComponent(projectId)}:`;
10
+ }
11
+
12
+ /** Device IDs are path segments in the filesystem relay and keys elsewhere. */
13
+ export function isSafeSyncDeviceId(value: string): boolean {
14
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
15
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Stable keyset cursors for append-only relay logs.
3
+ *
4
+ * An integer offset is not safe while another device appends a batch between
5
+ * two pages: a new key can move the offset and make an event disappear. The
6
+ * adapters use the last `(deviceId, sequence)` they returned instead.
7
+ */
8
+
9
+ export interface SyncPageCursor {
10
+ deviceId: string;
11
+ sequence: number;
12
+ }
13
+
14
+ export function encodePageCursor(cursor: SyncPageCursor): string {
15
+ return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
16
+ }
17
+
18
+ export function decodePageCursor(token: string | undefined): SyncPageCursor | undefined {
19
+ if (token === undefined) return undefined;
20
+ try {
21
+ const value = JSON.parse(Buffer.from(token, 'base64url').toString('utf8')) as Partial<SyncPageCursor>;
22
+ const sequence = value.sequence;
23
+ if (
24
+ typeof value.deviceId !== 'string'
25
+ || value.deviceId.length === 0
26
+ || typeof sequence !== 'number'
27
+ || !Number.isSafeInteger(sequence)
28
+ || sequence < 1
29
+ ) {
30
+ throw new Error('invalid cursor fields');
31
+ }
32
+ return { deviceId: value.deviceId, sequence };
33
+ } catch {
34
+ throw new Error('[memorix] sync page token is invalid');
35
+ }
36
+ }
37
+
38
+ export function comparePageKey(
39
+ left: Pick<SyncPageCursor, 'deviceId' | 'sequence'>,
40
+ right: Pick<SyncPageCursor, 'deviceId' | 'sequence'>,
41
+ ): number {
42
+ if (left.deviceId < right.deviceId) return -1;
43
+ if (left.deviceId > right.deviceId) return 1;
44
+ return left.sequence - right.sequence;
45
+ }