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.
- package/CHANGELOG.md +36 -0
- package/README.md +15 -1
- package/README.zh-CN.md +15 -1
- package/dist/cli/index.js +3722 -1599
- package/dist/cli/memcode.js +0 -0
- package/dist/index.js +634 -363
- package/dist/maintenance-runner.js +285 -103
- package/dist/media-audio-runner.js +285 -103
- package/dist/media-video-runner.js +285 -103
- package/dist/memcode-runtime/CHANGELOG.md +36 -0
- package/dist/sdk.js +627 -356
- package/dist/vector-backfill-runner.d.ts +4 -1
- package/dist/vector-backfill-runner.js +288 -104
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +4 -2
- package/docs/API_REFERENCE.md +14 -0
- package/docs/CONFIGURATION.md +46 -0
- package/docs/DEVELOPMENT.md +2 -2
- package/docs/INTEGRATIONS.md +1 -0
- package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +344 -0
- package/docs/PERFORMANCE.md +32 -0
- package/docs/README.md +4 -2
- package/docs/hooks-architecture.md +18 -13
- package/llms-full.txt +4 -1
- package/llms.txt +3 -1
- package/package.json +2 -1
- package/plugins/antigravity/memorix/plugin.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/hermes/memorix/plugin.yaml +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/command-guide.ts +9 -1
- package/src/cli/commands/agent-integrations.ts +2 -1
- package/src/cli/commands/hooks-install.ts +2 -0
- package/src/cli/commands/integrate.ts +2 -0
- package/src/cli/commands/serve-http.ts +11 -0
- package/src/cli/commands/setup.ts +11 -4
- package/src/cli/commands/sync-store.ts +191 -0
- package/src/cli/commands/sync.ts +8 -0
- package/src/hooks/handler.ts +6 -0
- package/src/hooks/installers/index.ts +181 -6
- package/src/hooks/normalizer.ts +60 -1
- package/src/hooks/types.ts +3 -1
- package/src/integrations/registry.ts +9 -0
- package/src/memory/observations.ts +15 -2
- package/src/runtime/maintenance-targets.ts +6 -0
- package/src/runtime/vector-backfill-runner.ts +185 -7
- package/src/store/orama-store.ts +85 -102
- package/src/sync/adapters/fs.ts +179 -0
- package/src/sync/adapters/github.ts +258 -0
- package/src/sync/adapters/object-store.ts +267 -0
- package/src/sync/adapters/postgres.ts +269 -0
- package/src/sync/engine.ts +353 -0
- package/src/sync/journal.ts +162 -0
- package/src/sync/merge.ts +116 -0
- package/src/sync/namespace.ts +15 -0
- package/src/sync/paging.ts +45 -0
- package/src/sync/policy.ts +37 -0
- package/src/sync/remote-factory.ts +49 -0
- package/src/sync/store-port.ts +288 -0
- package/src/sync/types.ts +183 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Observation } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export interface SyncEligibility {
|
|
4
|
+
eligible: boolean;
|
|
5
|
+
reason?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The first sync contract is deliberately conservative. A remote relay is not
|
|
10
|
+
* an agent-facing visibility boundary, so only durable project memory crosses
|
|
11
|
+
* it. Personal, team, candidate, and ephemeral records stay local.
|
|
12
|
+
*/
|
|
13
|
+
export function syncEligibility(observation: Observation, projectId: string): SyncEligibility {
|
|
14
|
+
if (observation.projectId !== projectId) return { eligible: false, reason: 'other-project' };
|
|
15
|
+
if ((observation.visibility ?? 'project') !== 'project') return { eligible: false, reason: 'non-project-visibility' };
|
|
16
|
+
if ((observation.sharedWithAgentIds?.length ?? 0) > 0) {
|
|
17
|
+
return { eligible: false, reason: 'agent-targeted' };
|
|
18
|
+
}
|
|
19
|
+
if (observation.admissionState === 'candidate' || observation.admissionState === 'ephemeral') {
|
|
20
|
+
return { eligible: false, reason: 'unqualified-observation' };
|
|
21
|
+
}
|
|
22
|
+
if (observation.valueCategory === 'ephemeral') return { eligible: false, reason: 'ephemeral-value' };
|
|
23
|
+
return { eligible: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function eligibleObservations(observations: Observation[], projectId: string): {
|
|
27
|
+
eligible: Observation[];
|
|
28
|
+
excluded: number;
|
|
29
|
+
} {
|
|
30
|
+
const eligible: Observation[] = [];
|
|
31
|
+
let excluded = 0;
|
|
32
|
+
for (const observation of observations) {
|
|
33
|
+
if (syncEligibility(observation, projectId).eligible) eligible.push(observation);
|
|
34
|
+
else excluded++;
|
|
35
|
+
}
|
|
36
|
+
return { eligible, excluded };
|
|
37
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a `SyncRemote` from configuration. Provider choice is data, not
|
|
3
|
+
* code: the caller picks a provider name and the matching adapter is
|
|
4
|
+
* lazy-imported so unused providers never load their optional SDKs.
|
|
5
|
+
*
|
|
6
|
+
* Configuration is env-driven and opt-in. Sync is OFF unless
|
|
7
|
+
* `MEMORIX_SYNC_PROVIDER` is set (or an explicit provider is passed).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SyncRemote } from './types.js';
|
|
11
|
+
|
|
12
|
+
export type SyncProvider = 'fs' | 'github' | 's3' | 'postgres';
|
|
13
|
+
|
|
14
|
+
export interface SyncConfig {
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
provider?: SyncProvider;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveSyncConfig(env: NodeJS.ProcessEnv = process.env): SyncConfig {
|
|
20
|
+
const provider = env.MEMORIX_SYNC_PROVIDER as SyncProvider | undefined;
|
|
21
|
+
if (!provider) return { enabled: false };
|
|
22
|
+
if (provider !== 'fs' && provider !== 'github' && provider !== 's3' && provider !== 'postgres') {
|
|
23
|
+
throw new Error(`[memorix] unknown MEMORIX_SYNC_PROVIDER "${provider}" (expected fs|github|s3|postgres)`);
|
|
24
|
+
}
|
|
25
|
+
return { enabled: true, provider };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function createRemote(
|
|
29
|
+
provider: SyncProvider,
|
|
30
|
+
namespace: string,
|
|
31
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
32
|
+
): Promise<SyncRemote> {
|
|
33
|
+
if (provider === 'fs') {
|
|
34
|
+
const root = env.MEMORIX_SYNC_FS_ROOT;
|
|
35
|
+
if (!root) throw new Error('[memorix] fs sync requires MEMORIX_SYNC_FS_ROOT');
|
|
36
|
+
const { FsRemote } = await import('./adapters/fs.js');
|
|
37
|
+
return new FsRemote({ root, namespace });
|
|
38
|
+
}
|
|
39
|
+
if (provider === 'github') {
|
|
40
|
+
const { createGitHubRemote } = await import('./adapters/github.js');
|
|
41
|
+
return createGitHubRemote(namespace, env);
|
|
42
|
+
}
|
|
43
|
+
if (provider === 's3') {
|
|
44
|
+
const { createS3ObjectStore, ObjectStoreRemote } = await import('./adapters/object-store.js');
|
|
45
|
+
return new ObjectStoreRemote(await createS3ObjectStore(env), namespace, env.MEMORIX_SYNC_S3_PREFIX ?? '');
|
|
46
|
+
}
|
|
47
|
+
const { createPgSqlClient, PostgresSyncRemote } = await import('./adapters/postgres.js');
|
|
48
|
+
return new PostgresSyncRemote(await createPgSqlClient(env), namespace);
|
|
49
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binds the sync engine's `SyncStorePort` to the real Memorix store: the
|
|
3
|
+
* canonical `ObservationStore` for rows, plus the additive `sync_row_state` /
|
|
4
|
+
* `sync_meta` tables (created here, never altering `observations`).
|
|
5
|
+
*
|
|
6
|
+
* This is the only place the engine touches persistence in production.
|
|
7
|
+
*/
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import type { Observation } from '../types.js';
|
|
11
|
+
import type { ObservationStore } from '../store/obs-store.js';
|
|
12
|
+
import { getDatabase } from '../store/sqlite-db.js';
|
|
13
|
+
import { CREATE_SYNC_TABLES } from './journal.js';
|
|
14
|
+
import { syncNamespace } from './namespace.js';
|
|
15
|
+
import type { SyncStorePort } from './engine.js';
|
|
16
|
+
import type { SyncConflict, SyncCursor, SyncPendingBatch, SyncRowState } from './types.js';
|
|
17
|
+
|
|
18
|
+
const SEQ_KEY = 'local_sequence';
|
|
19
|
+
const DEVICE_KEY = 'device_id';
|
|
20
|
+
const FINGERPRINT_KEY = 'device_fingerprint';
|
|
21
|
+
const LEGACY_PROJECT_ID = '';
|
|
22
|
+
|
|
23
|
+
export interface SqliteSyncStore extends SyncStorePort {
|
|
24
|
+
/** Stable device id for this store (generated once, persisted in sync_meta). */
|
|
25
|
+
deviceId(): string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createSqliteSyncStore(dataDir: string, store: ObservationStore, projectId: string): SqliteSyncStore {
|
|
29
|
+
const db = getDatabase(dataDir);
|
|
30
|
+
ensureSyncSchema(db, projectId);
|
|
31
|
+
const getMeta = db.prepare('SELECT value FROM sync_meta WHERE key = ?');
|
|
32
|
+
const setMeta = db.prepare('INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)');
|
|
33
|
+
const selState = db.prepare('SELECT projectId, syncKey, obsId, revision, writer, kind, contentHash, shippedSeq FROM sync_row_state WHERE projectId = ?');
|
|
34
|
+
const upState = db.prepare(
|
|
35
|
+
`INSERT INTO sync_row_state (projectId, syncKey, obsId, revision, writer, kind, contentHash, shippedSeq)
|
|
36
|
+
VALUES (@projectId, @syncKey, @obsId, @revision, @writer, @kind, @contentHash, @shippedSeq)
|
|
37
|
+
ON CONFLICT(projectId, syncKey) DO UPDATE SET
|
|
38
|
+
obsId = excluded.obsId,
|
|
39
|
+
revision = excluded.revision,
|
|
40
|
+
writer = excluded.writer,
|
|
41
|
+
kind = excluded.kind,
|
|
42
|
+
contentHash = excluded.contentHash,
|
|
43
|
+
shippedSeq = excluded.shippedSeq`,
|
|
44
|
+
);
|
|
45
|
+
const insertConflict = db.prepare(
|
|
46
|
+
`INSERT OR IGNORE INTO sync_conflicts (id, projectId, syncKey, winnerJson, loserJson, createdAt, reason)
|
|
47
|
+
VALUES (@id, @projectId, @syncKey, @winnerJson, @loserJson, @createdAt, @reason)`,
|
|
48
|
+
);
|
|
49
|
+
const selPending = db.prepare(
|
|
50
|
+
`SELECT batchJson, statesJson FROM sync_outbox
|
|
51
|
+
WHERE projectId = ? AND namespace = ? AND deviceId = ? ORDER BY sequence ASC`,
|
|
52
|
+
);
|
|
53
|
+
const insertPending = db.prepare(
|
|
54
|
+
`INSERT OR IGNORE INTO sync_outbox
|
|
55
|
+
(projectId, namespace, deviceId, sequence, batchJson, statesJson, createdAt)
|
|
56
|
+
VALUES (@projectId, @namespace, @deviceId, @sequence, @batchJson, @statesJson, @createdAt)`,
|
|
57
|
+
);
|
|
58
|
+
const deletePending = db.prepare(
|
|
59
|
+
`DELETE FROM sync_outbox WHERE projectId = ? AND namespace = ? AND deviceId = ? AND sequence = ?`,
|
|
60
|
+
);
|
|
61
|
+
const pendingForDevice = db.prepare(
|
|
62
|
+
`SELECT projectId, namespace, sequence, batchJson
|
|
63
|
+
FROM sync_outbox WHERE deviceId = ? ORDER BY projectId, namespace, sequence`,
|
|
64
|
+
);
|
|
65
|
+
const rewritePendingDevice = db.prepare(
|
|
66
|
+
`UPDATE sync_outbox
|
|
67
|
+
SET deviceId = ?, batchJson = ?
|
|
68
|
+
WHERE projectId = ? AND namespace = ? AND deviceId = ? AND sequence = ?`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const namespace = syncNamespace(projectId);
|
|
72
|
+
const deviceFingerprint = process.env.MEMORIX_SYNC_DEVICE_FINGERPRINT?.trim()
|
|
73
|
+
|| `${process.platform}|${os.hostname()}|${os.homedir()}`;
|
|
74
|
+
|
|
75
|
+
function ensureDevice(): string {
|
|
76
|
+
const row = getMeta.get(DEVICE_KEY) as { value: string } | undefined;
|
|
77
|
+
if (row?.value) return row.value;
|
|
78
|
+
const id = `dev_${randomUUID().replaceAll('-', '')}`;
|
|
79
|
+
setMeta.run(DEVICE_KEY, id);
|
|
80
|
+
setMeta.run(FINGERPRINT_KEY, deviceFingerprint);
|
|
81
|
+
return id;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function assertCanPublish(): void {
|
|
85
|
+
const device = getMeta.get(DEVICE_KEY) as { value: string } | undefined;
|
|
86
|
+
if (!device?.value) {
|
|
87
|
+
ensureDevice();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const row = getMeta.get(FINGERPRINT_KEY) as { value: string } | undefined;
|
|
91
|
+
if (!row?.value) throw new Error('[memorix] sync device identity is unknown; run `memorix sync store device rotate` before publishing');
|
|
92
|
+
if (row?.value && row.value !== deviceFingerprint) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
'[memorix] sync device clone detected; run `memorix sync store device rotate` before publishing from this data directory',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function cursorKey(): string {
|
|
100
|
+
return `cursor:${namespace}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
projectId: () => projectId,
|
|
105
|
+
namespace: () => namespace,
|
|
106
|
+
deviceId: ensureDevice,
|
|
107
|
+
assertCanPublish,
|
|
108
|
+
rotateDevice: () => {
|
|
109
|
+
const previous = ensureDevice();
|
|
110
|
+
const id = `dev_${randomUUID().replaceAll('-', '')}`;
|
|
111
|
+
const pending = pendingForDevice.all(previous) as Array<{
|
|
112
|
+
projectId: string;
|
|
113
|
+
namespace: string;
|
|
114
|
+
sequence: number;
|
|
115
|
+
batchJson: string;
|
|
116
|
+
}>;
|
|
117
|
+
const rotate = db.transaction(() => {
|
|
118
|
+
setMeta.run(DEVICE_KEY, id);
|
|
119
|
+
setMeta.run(FINGERPRINT_KEY, deviceFingerprint);
|
|
120
|
+
// Pending batches were created before the clone was detected. Keep
|
|
121
|
+
// them and move their transport identity to the new replica; deleting
|
|
122
|
+
// them here would silently lose successfully recorded local memory.
|
|
123
|
+
for (const item of pending) {
|
|
124
|
+
const batch = JSON.parse(item.batchJson) as { deviceId?: string } & Record<string, unknown>;
|
|
125
|
+
rewritePendingDevice.run(
|
|
126
|
+
id,
|
|
127
|
+
JSON.stringify({ ...batch, deviceId: id }),
|
|
128
|
+
item.projectId,
|
|
129
|
+
item.namespace,
|
|
130
|
+
previous,
|
|
131
|
+
item.sequence,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
rotate();
|
|
136
|
+
return id;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async loadCursor(): Promise<SyncCursor> {
|
|
140
|
+
const row = getMeta.get(cursorKey()) as { value: string } | undefined;
|
|
141
|
+
if (!row) return { applied: {} };
|
|
142
|
+
try {
|
|
143
|
+
const value = JSON.parse(row.value) as SyncCursor;
|
|
144
|
+
return value && typeof value.applied === 'object' ? value : { applied: {} };
|
|
145
|
+
} catch {
|
|
146
|
+
return { applied: {} };
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async saveCursor(cursor: SyncCursor): Promise<void> {
|
|
151
|
+
setMeta.run(cursorKey(), JSON.stringify(cursor));
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async loadPendingBatches(): Promise<SyncPendingBatch[]> {
|
|
155
|
+
return (selPending.all(projectId, namespace, ensureDevice()) as Array<{ batchJson: string; statesJson: string }>).map((row) => ({
|
|
156
|
+
batch: JSON.parse(row.batchJson),
|
|
157
|
+
states: JSON.parse(row.statesJson),
|
|
158
|
+
} as SyncPendingBatch));
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async enqueueBatch(pending: SyncPendingBatch): Promise<void> {
|
|
162
|
+
insertPending.run({
|
|
163
|
+
projectId,
|
|
164
|
+
namespace,
|
|
165
|
+
deviceId: pending.batch.deviceId,
|
|
166
|
+
sequence: pending.batch.sequence,
|
|
167
|
+
batchJson: JSON.stringify(pending.batch),
|
|
168
|
+
statesJson: JSON.stringify(pending.states),
|
|
169
|
+
createdAt: pending.batch.producedAt,
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async markBatchShipped(sequence: number): Promise<void> {
|
|
174
|
+
deletePending.run(projectId, namespace, ensureDevice(), sequence);
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
async loadAll(): Promise<Observation[]> {
|
|
178
|
+
return store.loadByProject(projectId);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async loadState(): Promise<Map<string, SyncRowState>> {
|
|
182
|
+
const rows = selState.all(projectId) as SyncRowState[];
|
|
183
|
+
const map = new Map<string, SyncRowState>();
|
|
184
|
+
for (const r of rows) map.set(r.syncKey, r);
|
|
185
|
+
return map;
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
async saveState(rows: SyncRowState[]): Promise<void> {
|
|
189
|
+
const tx = db.transaction((batch: SyncRowState[]) => {
|
|
190
|
+
for (const r of batch) upState.run(r);
|
|
191
|
+
});
|
|
192
|
+
tx(rows);
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async allocateId(): Promise<number> {
|
|
196
|
+
return store.atomic(async (tx) => {
|
|
197
|
+
const nextId = await tx.loadIdCounter();
|
|
198
|
+
await tx.saveIdCounter(nextId + 1);
|
|
199
|
+
return nextId;
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
async applyInsert(row: Observation, state: SyncRowState): Promise<void> {
|
|
204
|
+
await store.atomic(async (tx) => {
|
|
205
|
+
const existing = await tx.getById(row.id);
|
|
206
|
+
if (existing) {
|
|
207
|
+
if (existing.projectId !== row.projectId) {
|
|
208
|
+
throw new Error('[memorix] sync refused to overwrite an observation from another project');
|
|
209
|
+
}
|
|
210
|
+
throw new Error('[memorix] sync insert collided with an existing local observation id');
|
|
211
|
+
}
|
|
212
|
+
await tx.insert(row);
|
|
213
|
+
upState.run(state);
|
|
214
|
+
});
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
async applyUpdate(row: Observation, state: SyncRowState): Promise<void> {
|
|
218
|
+
await store.atomic(async (tx) => {
|
|
219
|
+
const existing = await tx.getById(row.id);
|
|
220
|
+
if (existing && existing.projectId !== row.projectId) {
|
|
221
|
+
throw new Error('[memorix] sync refused to overwrite an observation from another project');
|
|
222
|
+
}
|
|
223
|
+
if (existing) await tx.update(row);
|
|
224
|
+
else await tx.insert(row);
|
|
225
|
+
upState.run(state);
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
async applyRemove(id: number, state: SyncRowState): Promise<void> {
|
|
230
|
+
await store.atomic(async (tx) => {
|
|
231
|
+
const existing = await tx.getById(id);
|
|
232
|
+
if (existing && existing.projectId !== state.projectId) {
|
|
233
|
+
throw new Error('[memorix] sync refused to remove an observation from another project');
|
|
234
|
+
}
|
|
235
|
+
await tx.remove(id);
|
|
236
|
+
upState.run(state);
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async getById(id: number): Promise<Observation | undefined> {
|
|
241
|
+
return store.getById(id);
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
async recordConflict(conflict: SyncConflict): Promise<void> {
|
|
245
|
+
insertConflict.run({
|
|
246
|
+
id: conflict.id,
|
|
247
|
+
projectId: conflict.projectId,
|
|
248
|
+
syncKey: conflict.syncKey,
|
|
249
|
+
winnerJson: JSON.stringify(conflict.winner),
|
|
250
|
+
loserJson: JSON.stringify({ version: conflict.loser, kind: conflict.loserKind, row: conflict.loserRow }),
|
|
251
|
+
createdAt: conflict.createdAt,
|
|
252
|
+
reason: conflict.reason,
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async nextSequence(): Promise<number> {
|
|
257
|
+
const row = getMeta.get(SEQ_KEY) as { value: string } | undefined;
|
|
258
|
+
const next = (row ? parseInt(row.value, 10) : 0) + 1;
|
|
259
|
+
setMeta.run(SEQ_KEY, String(next));
|
|
260
|
+
return next;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function ensureSyncSchema(db: any, projectId: string): void {
|
|
266
|
+
db.exec(CREATE_SYNC_TABLES);
|
|
267
|
+
const columns = db.prepare('PRAGMA table_info(sync_row_state)').all() as Array<{ name: string }>;
|
|
268
|
+
if (!columns.some((column) => column.name === 'projectId')) {
|
|
269
|
+
// #277 created this table with syncKey as the only primary key. Adding a
|
|
270
|
+
// nullable-by-default project column keeps old stores readable while the
|
|
271
|
+
// composite unique index enables the scoped v3 writer.
|
|
272
|
+
db.exec("ALTER TABLE sync_row_state ADD COLUMN projectId TEXT NOT NULL DEFAULT ''");
|
|
273
|
+
}
|
|
274
|
+
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS sync_row_state_project_key ON sync_row_state(projectId, syncKey)');
|
|
275
|
+
db.exec('CREATE INDEX IF NOT EXISTS sync_row_state_project_idx ON sync_row_state(projectId)');
|
|
276
|
+
db.exec('CREATE INDEX IF NOT EXISTS sync_outbox_project_idx ON sync_outbox(projectId, namespace, deviceId, sequence)');
|
|
277
|
+
|
|
278
|
+
// Attach legacy live rows to the project they already belong to. Legacy
|
|
279
|
+
// tombstones have no surviving observation from which a project can be
|
|
280
|
+
// inferred, so they remain quarantined under the empty project ID.
|
|
281
|
+
const legacy = db.prepare(
|
|
282
|
+
`SELECT s.syncKey, s.obsId FROM sync_row_state s
|
|
283
|
+
JOIN observations o ON o.id = s.obsId
|
|
284
|
+
WHERE s.projectId = '' AND o.projectId = ?`,
|
|
285
|
+
).all(projectId) as Array<{ syncKey: string; obsId: number }>;
|
|
286
|
+
const update = db.prepare('UPDATE sync_row_state SET projectId = ? WHERE syncKey = ? AND projectId = ?');
|
|
287
|
+
for (const row of legacy) update.run(projectId, row.syncKey, LEGACY_PROJECT_ID);
|
|
288
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-device store sync — shared contracts.
|
|
3
|
+
*
|
|
4
|
+
* One feature, interchangeable remotes. The merge engine never talks to a
|
|
5
|
+
* vendor; it talks to `SyncRemote`. A remote can be a local directory, an
|
|
6
|
+
* S3-compatible object store, or a Postgres hub — chosen by configuration.
|
|
7
|
+
*
|
|
8
|
+
* Local-first is preserved: the canonical store stays local SQLite on every
|
|
9
|
+
* machine. Sync is opt-in, asynchronous, and off the hot path.
|
|
10
|
+
*
|
|
11
|
+
* Identity & versioning (why this converges):
|
|
12
|
+
* - Rows are merged by a stable `syncKey`, not by the replica-local integer
|
|
13
|
+
* id. Two machines that independently insert new rows never collide, and a
|
|
14
|
+
* topic-keyed observation carries the same key everywhere.
|
|
15
|
+
* - A row's version is a per-row logical clock `(revision, writer)`. Merge is
|
|
16
|
+
* a single total order over that tuple, so replaying batches in any order
|
|
17
|
+
* reaches the same final state (last-writer-wins, convergent).
|
|
18
|
+
*/
|
|
19
|
+
import type { Observation } from '../types.js';
|
|
20
|
+
|
|
21
|
+
/** Kind of change carried in the journal. */
|
|
22
|
+
export type ChangeKind = 'upsert' | 'tombstone';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A per-row logical version. Larger wins under last-writer-wins. Comparison is
|
|
26
|
+
* lexicographic over `(revision, writer)` and is a strict total order, which is
|
|
27
|
+
* what makes replay convergent regardless of delivery order.
|
|
28
|
+
*/
|
|
29
|
+
export interface RowVersion {
|
|
30
|
+
/** Per-row Lamport counter: max revision observed for this row + 1. */
|
|
31
|
+
revision: number;
|
|
32
|
+
/** Stable id of the device that produced this version (deterministic tiebreak). */
|
|
33
|
+
writer: string;
|
|
34
|
+
/** Informational ISO timestamp; never used in comparison. */
|
|
35
|
+
at?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One replicated change for a single logical row, addressed by `syncKey`. */
|
|
39
|
+
export interface ChangeEntry {
|
|
40
|
+
/** Stable cross-device identity of the row (see module doc). */
|
|
41
|
+
syncKey: string;
|
|
42
|
+
kind: ChangeKind;
|
|
43
|
+
version: RowVersion;
|
|
44
|
+
/** SHA-256 of the canonical row body; empty for tombstones. */
|
|
45
|
+
contentHash: string;
|
|
46
|
+
/** Present when kind === 'upsert'; omitted for tombstones. */
|
|
47
|
+
row?: Observation;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A batch of changes shipped in one direction. */
|
|
51
|
+
export interface ChangeBatch {
|
|
52
|
+
/** Schema version of the batch envelope. */
|
|
53
|
+
formatVersion: 3;
|
|
54
|
+
/** Hash-like remote namespace for one canonical Git project. */
|
|
55
|
+
namespace: string;
|
|
56
|
+
/** Canonical Memorix project ID. */
|
|
57
|
+
projectId: string;
|
|
58
|
+
/** Device that produced the batch. */
|
|
59
|
+
deviceId: string;
|
|
60
|
+
/** Monotonic sequence for this device; lets remotes dedupe/order. */
|
|
61
|
+
sequence: number;
|
|
62
|
+
/** ISO timestamp the batch was produced. */
|
|
63
|
+
producedAt: string;
|
|
64
|
+
entries: ChangeEntry[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Authoritative local record of the version we currently hold for a row,
|
|
69
|
+
* keyed by `syncKey`. It serves double duty: change-detection basis for push
|
|
70
|
+
* and the merge basis for pull, so a locally applied remote row is never
|
|
71
|
+
* re-shipped in a loop. Persisted in the additive `sync_row_state` table.
|
|
72
|
+
*/
|
|
73
|
+
export interface SyncRowState {
|
|
74
|
+
/** Canonical project that owns this row. */
|
|
75
|
+
projectId: string;
|
|
76
|
+
syncKey: string;
|
|
77
|
+
/** Local integer id of the row, or null once tombstoned. */
|
|
78
|
+
obsId: number | null;
|
|
79
|
+
revision: number;
|
|
80
|
+
writer: string;
|
|
81
|
+
kind: ChangeKind;
|
|
82
|
+
/** Content fingerprint of the row body (excludes replica-local fields). */
|
|
83
|
+
contentHash: string;
|
|
84
|
+
/** Sequence of the batch that last shipped this state locally. */
|
|
85
|
+
shippedSeq: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Opaque per-remote cursor. Maps deviceId -> last applied sequence, so both
|
|
90
|
+
* sides know what the other has already seen. Remotes persist and return it.
|
|
91
|
+
*/
|
|
92
|
+
export interface SyncCursor {
|
|
93
|
+
/** deviceId -> highest applied sequence from that device. */
|
|
94
|
+
applied: Record<string, number>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function emptyCursor(): SyncCursor {
|
|
98
|
+
return { applied: {} };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The only surface the engine uses to reach a backend. Every adapter (fs,
|
|
103
|
+
* object-store, postgres, ...) implements exactly this. No adapter is
|
|
104
|
+
* privileged; provider choice is configuration, not code.
|
|
105
|
+
*/
|
|
106
|
+
export interface SyncRemote {
|
|
107
|
+
/** Namespace this remote instance is allowed to read and write. */
|
|
108
|
+
readonly namespace: string;
|
|
109
|
+
/** Human-readable id for logs and status (e.g. "fs", "s3", "postgres"). */
|
|
110
|
+
readonly kind: string;
|
|
111
|
+
/** Open connections / ensure containers exist. Safe to call repeatedly. */
|
|
112
|
+
init(options?: { create?: boolean }): Promise<void>;
|
|
113
|
+
/** Upload one batch of local changes. Idempotent by (deviceId, sequence). */
|
|
114
|
+
push(batch: ChangeBatch): Promise<void>;
|
|
115
|
+
/**
|
|
116
|
+
* Download batches this device has not applied yet. `since` maps
|
|
117
|
+
* deviceId -> highest sequence already applied locally; the remote returns
|
|
118
|
+
* only newer batches, oldest first.
|
|
119
|
+
*/
|
|
120
|
+
pull(since: Record<string, number>, limit: number, pageToken?: string): Promise<SyncPullPage>;
|
|
121
|
+
/** Explicitly remove events that every operator-supplied device cursor passed. */
|
|
122
|
+
compact(through: Record<string, number>, options?: { dryRun?: boolean }): Promise<SyncCompactReport>;
|
|
123
|
+
/** Release connections / flush. Safe to call when never initialized. */
|
|
124
|
+
close(): Promise<void>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface SyncCompactReport {
|
|
128
|
+
candidates: number;
|
|
129
|
+
deleted: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface SyncPullPage {
|
|
133
|
+
batches: ChangeBatch[];
|
|
134
|
+
hasMore: boolean;
|
|
135
|
+
/** Opaque provider page token for the next call in the same run. */
|
|
136
|
+
nextPageToken?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface SyncPendingBatch {
|
|
140
|
+
batch: ChangeBatch;
|
|
141
|
+
states: SyncRowState[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface SyncConflict {
|
|
145
|
+
id: string;
|
|
146
|
+
projectId: string;
|
|
147
|
+
syncKey: string;
|
|
148
|
+
winner: RowVersion;
|
|
149
|
+
loser: RowVersion;
|
|
150
|
+
loserKind: ChangeKind;
|
|
151
|
+
loserRow?: Observation;
|
|
152
|
+
createdAt: string;
|
|
153
|
+
reason: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Result of a push/pull/status run, for CLI reporting. */
|
|
157
|
+
export interface SyncReport {
|
|
158
|
+
remote: string;
|
|
159
|
+
namespace: string;
|
|
160
|
+
projectId: string;
|
|
161
|
+
deviceId: string;
|
|
162
|
+
pushed: number;
|
|
163
|
+
pulledBatches: number;
|
|
164
|
+
applied: number;
|
|
165
|
+
skipped: number;
|
|
166
|
+
tombstoned: number;
|
|
167
|
+
eligible: number;
|
|
168
|
+
excluded: number;
|
|
169
|
+
conflicts: number;
|
|
170
|
+
pending: number;
|
|
171
|
+
dryRun: boolean;
|
|
172
|
+
/** Per-change decisions, populated on dry runs and verbose runs. */
|
|
173
|
+
decisions: SyncDecision[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type SyncOutcome = 'apply' | 'skip-older' | 'skip-equal';
|
|
177
|
+
|
|
178
|
+
export interface SyncDecision {
|
|
179
|
+
syncKey: string;
|
|
180
|
+
kind: ChangeKind;
|
|
181
|
+
outcome: SyncOutcome;
|
|
182
|
+
reason: string;
|
|
183
|
+
}
|