memorix 1.8.9 → 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 (53) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +7 -0
  3. package/README.zh-CN.md +6 -0
  4. package/dist/cli/index.js +3261 -1348
  5. package/dist/index.js +324 -187
  6. package/dist/maintenance-runner.js +285 -103
  7. package/dist/media-audio-runner.js +285 -103
  8. package/dist/media-video-runner.js +285 -103
  9. package/dist/memcode-runtime/CHANGELOG.md +18 -0
  10. package/dist/sdk.js +323 -186
  11. package/dist/vector-backfill-runner.d.ts +4 -1
  12. package/dist/vector-backfill-runner.js +288 -104
  13. package/docs/API_REFERENCE.md +14 -0
  14. package/docs/CONFIGURATION.md +46 -0
  15. package/docs/DEVELOPMENT.md +2 -2
  16. package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +344 -0
  17. package/docs/PERFORMANCE.md +32 -0
  18. package/docs/README.md +4 -2
  19. package/llms-full.txt +1 -0
  20. package/llms.txt +1 -0
  21. package/package.json +1 -1
  22. package/plugins/antigravity/memorix/plugin.json +1 -1
  23. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  24. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  25. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  26. package/plugins/copilot/memorix/plugin.json +1 -1
  27. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  28. package/plugins/hermes/memorix/plugin.yaml +1 -1
  29. package/plugins/omp/memorix/package.json +1 -1
  30. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  31. package/plugins/pi/memorix/package.json +1 -1
  32. package/server.json +2 -2
  33. package/src/cli/command-guide.ts +9 -1
  34. package/src/cli/commands/serve-http.ts +11 -0
  35. package/src/cli/commands/sync-store.ts +191 -0
  36. package/src/cli/commands/sync.ts +8 -0
  37. package/src/memory/observations.ts +15 -2
  38. package/src/runtime/maintenance-targets.ts +6 -0
  39. package/src/runtime/vector-backfill-runner.ts +185 -7
  40. package/src/store/orama-store.ts +85 -102
  41. package/src/sync/adapters/fs.ts +179 -0
  42. package/src/sync/adapters/github.ts +258 -0
  43. package/src/sync/adapters/object-store.ts +267 -0
  44. package/src/sync/adapters/postgres.ts +269 -0
  45. package/src/sync/engine.ts +353 -0
  46. package/src/sync/journal.ts +162 -0
  47. package/src/sync/merge.ts +116 -0
  48. package/src/sync/namespace.ts +15 -0
  49. package/src/sync/paging.ts +45 -0
  50. package/src/sync/policy.ts +37 -0
  51. package/src/sync/remote-factory.ts +49 -0
  52. package/src/sync/store-port.ts +288 -0
  53. package/src/sync/types.ts +183 -0
@@ -0,0 +1,258 @@
1
+ import type { ChangeBatch, SyncCompactReport, SyncPullPage, SyncRemote } from '../types.js';
2
+ import { comparePageKey, decodePageCursor, encodePageCursor } from '../paging.js';
3
+ import { isSafeSyncDeviceId } from '../namespace.js';
4
+
5
+ interface GitHubRemoteOptions {
6
+ repo: string;
7
+ token: string;
8
+ branch: string;
9
+ namespace: string;
10
+ apiBaseUrl: string;
11
+ }
12
+
13
+ interface GitHubTreeEntry {
14
+ path?: string;
15
+ type?: string;
16
+ sha?: string;
17
+ }
18
+
19
+ interface GitHubTreeResponse {
20
+ tree?: GitHubTreeEntry[];
21
+ truncated?: boolean;
22
+ }
23
+
24
+ interface GitHubRefResponse {
25
+ object?: { sha?: string };
26
+ }
27
+
28
+ interface GitHubCommitResponse {
29
+ tree?: { sha?: string };
30
+ }
31
+
32
+ interface GitHubContentResponse {
33
+ content?: string;
34
+ encoding?: string;
35
+ }
36
+
37
+ /**
38
+ * GitHub is an event relay only. It never receives memorix.db or a WAL file.
39
+ * Each batch is an immutable JSONL blob under one project/device namespace.
40
+ */
41
+ export class GitHubRemote implements SyncRemote {
42
+ readonly kind = 'github';
43
+ readonly namespace: string;
44
+ private branchExists = false;
45
+
46
+ constructor(private readonly options: GitHubRemoteOptions) {
47
+ this.namespace = options.namespace;
48
+ }
49
+
50
+ async init(options: { create?: boolean } = {}): Promise<void> {
51
+ this.branchExists = false;
52
+ const repository = await this.request<{ default_branch?: string }>(`/repos/${this.options.repo}`);
53
+ const branch = encodeURIComponent(this.options.branch);
54
+ try {
55
+ const ref = await this.request<GitHubRefResponse>(`/repos/${this.options.repo}/git/ref/heads/${branch}`);
56
+ if (!ref.object?.sha) throw new Error('[memorix] GitHub relay returned a branch without a commit');
57
+ this.branchExists = true;
58
+ } catch (error) {
59
+ if (!isStatus(error, 404) || !repository.default_branch) throw error;
60
+ if (options.create === false) return;
61
+ const base = await this.request<{ object?: { sha?: string } }>(
62
+ `/repos/${this.options.repo}/git/ref/heads/${encodeURIComponent(repository.default_branch)}`,
63
+ );
64
+ if (!base.object?.sha) throw new Error('[memorix] GitHub relay could not resolve the default branch');
65
+ await this.request(`/repos/${this.options.repo}/git/refs`, {
66
+ method: 'POST',
67
+ body: JSON.stringify({ ref: `refs/heads/${this.options.branch}`, sha: base.object.sha }),
68
+ });
69
+ this.branchExists = true;
70
+ }
71
+ }
72
+
73
+ async push(batch: ChangeBatch): Promise<void> {
74
+ assertBatchNamespace(batch, this.namespace);
75
+ if (!this.branchExists) throw new Error('[memorix] GitHub sync remote is not initialized for writes');
76
+ const path = `events/${this.namespace}/${encodeURIComponent(batch.deviceId)}/${String(batch.sequence).padStart(20, '0')}.jsonl`;
77
+ const body = Buffer.from(`${JSON.stringify(batch)}\n`, 'utf8').toString('base64');
78
+ const apiPath = `/repos/${this.options.repo}/contents/${path.split('/').map(encodeURIComponent).join('/')}`;
79
+ let lastError: unknown;
80
+ for (let attempt = 0; attempt < 3; attempt++) {
81
+ try {
82
+ await this.request(apiPath, {
83
+ method: 'PUT',
84
+ body: JSON.stringify({
85
+ message: `memorix sync ${this.namespace}/${batch.deviceId}/${batch.sequence}`,
86
+ content: body,
87
+ branch: this.options.branch,
88
+ }),
89
+ });
90
+ return;
91
+ } catch (error) {
92
+ lastError = error;
93
+ if (!isStatus(error, 409) && !isStatus(error, 422)) throw error;
94
+ // A previous process may have committed the immutable event before a
95
+ // network retry reached us. Treat an identical existing blob as a
96
+ // successful idempotent write; reject a different payload.
97
+ const existing = await this.request<GitHubContentResponse>(`${apiPath}?ref=${encodeURIComponent(this.options.branch)}`);
98
+ if (existing.encoding === 'base64' && normalizeBase64(existing.content) === normalizeBase64(body)) return;
99
+ throw new Error('[memorix] GitHub relay event path already contains a different payload');
100
+ }
101
+ }
102
+ throw lastError instanceof Error ? lastError : new Error('[memorix] GitHub relay push failed');
103
+ }
104
+
105
+ async pull(since: Record<string, number>, limit: number, pageToken?: string): Promise<SyncPullPage> {
106
+ assertLimit(limit);
107
+ if (!this.branchExists) return { batches: [], hasMore: false };
108
+ const tree = await this.getTree();
109
+ if (tree.truncated) throw new Error('[memorix] GitHub relay tree is too large; compact the sync repository first');
110
+
111
+ const objects = (tree.tree ?? [])
112
+ .filter((entry): entry is { path: string; type: 'blob'; sha?: string } => typeof entry.path === 'string' && entry.type === 'blob')
113
+ .map((entry) => {
114
+ const prefix = `events/${this.namespace}/`;
115
+ if (!entry.path.startsWith(prefix)) return undefined;
116
+ const match = new RegExp(`^events/${escapeRegExp(this.namespace)}/([^/]+)/(\\d+)\\.jsonl$`).exec(entry.path);
117
+ if (!match) return undefined;
118
+ return { path: entry.path, sha: entry.sha, deviceId: decodeURIComponent(match[1]), sequence: Number(match[2]) };
119
+ })
120
+ .filter((entry): entry is { path: string; sha: string | undefined; deviceId: string; sequence: number } => entry !== undefined)
121
+ .filter((entry) => Number.isSafeInteger(entry.sequence) && entry.sequence > (since[entry.deviceId] ?? 0))
122
+ .sort((left, right) => left.deviceId === right.deviceId
123
+ ? left.sequence - right.sequence
124
+ : left.deviceId < right.deviceId ? -1 : 1);
125
+
126
+ const after = decodePageCursor(pageToken);
127
+ const visibleObjects = objects.filter((object) => !after || comparePageKey(object, after) > 0);
128
+ const pageObjects = visibleObjects.slice(0, limit);
129
+ const batches: ChangeBatch[] = [];
130
+ for (const object of pageObjects) {
131
+ const path = object.path.split('/').map(encodeURIComponent).join('/');
132
+ const content = await this.request<GitHubContentResponse>(
133
+ `/repos/${this.options.repo}/contents/${path}?ref=${encodeURIComponent(this.options.branch)}`,
134
+ );
135
+ if (content.encoding !== 'base64' || typeof content.content !== 'string') {
136
+ throw new Error('[memorix] GitHub relay returned a non-base64 event object');
137
+ }
138
+ const text = Buffer.from(content.content.replace(/\s/g, ''), 'base64').toString('utf8');
139
+ for (const line of text.split(/\r?\n/).filter(Boolean)) batches.push(JSON.parse(line) as ChangeBatch);
140
+ }
141
+ const hasMore = visibleObjects.length > pageObjects.length;
142
+ return {
143
+ batches,
144
+ hasMore,
145
+ nextPageToken: hasMore && pageObjects.length > 0
146
+ ? encodePageCursor(pageObjects[pageObjects.length - 1])
147
+ : undefined,
148
+ };
149
+ }
150
+
151
+ async compact(through: Record<string, number>, options: { dryRun?: boolean } = {}): Promise<SyncCompactReport> {
152
+ if (!this.branchExists) return { candidates: 0, deleted: 0 };
153
+ const tree = await this.getTree();
154
+ const objects = (tree.tree ?? []).flatMap((entry) => {
155
+ if (!entry.path || !entry.sha) return [];
156
+ const match = new RegExp(`^events/${escapeRegExp(this.namespace)}/([^/]+)/(\\d+)\\.jsonl$`).exec(entry.path);
157
+ if (!match) return [];
158
+ const sequence = Number(match[2]);
159
+ return sequence <= (through[decodeURIComponent(match[1])] ?? -1) ? [{ path: entry.path, sha: entry.sha }] : [];
160
+ });
161
+ if (!options.dryRun) {
162
+ for (const object of objects) {
163
+ const path = object.path.split('/').map(encodeURIComponent).join('/');
164
+ await this.request(`/repos/${this.options.repo}/contents/${path}`, {
165
+ method: 'DELETE',
166
+ body: JSON.stringify({ message: `memorix compact ${this.namespace}`, sha: object.sha, branch: this.options.branch }),
167
+ });
168
+ }
169
+ }
170
+ return { candidates: objects.length, deleted: options.dryRun ? 0 : objects.length };
171
+ }
172
+
173
+ async close(): Promise<void> {}
174
+
175
+ private async getTree(): Promise<GitHubTreeResponse> {
176
+ if (!this.branchExists) return { tree: [] };
177
+ const ref = await this.request<GitHubRefResponse>(
178
+ `/repos/${this.options.repo}/git/ref/heads/${encodeURIComponent(this.options.branch)}`,
179
+ );
180
+ const commitSha = ref.object?.sha;
181
+ if (!commitSha) throw new Error('[memorix] GitHub relay returned a branch without a commit');
182
+ const commit = await this.request<GitHubCommitResponse>(
183
+ `/repos/${this.options.repo}/git/commits/${encodeURIComponent(commitSha)}`,
184
+ );
185
+ if (!commit.tree?.sha) throw new Error('[memorix] GitHub relay could not resolve the branch tree');
186
+ return this.request<GitHubTreeResponse>(
187
+ `/repos/${this.options.repo}/git/trees/${encodeURIComponent(commit.tree.sha)}?recursive=1`,
188
+ );
189
+ }
190
+
191
+ private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
192
+ const response = await fetch(`${this.options.apiBaseUrl}${path}`, {
193
+ ...init,
194
+ headers: {
195
+ Accept: 'application/vnd.github+json',
196
+ Authorization: `Bearer ${this.options.token}`,
197
+ 'X-GitHub-Api-Version': '2022-11-28',
198
+ 'Content-Type': 'application/json',
199
+ ...(init.headers ?? {}),
200
+ },
201
+ });
202
+ if (!response.ok) {
203
+ const detail = await response.text().catch(() => '');
204
+ const error = new Error(`[memorix] GitHub relay HTTP ${response.status}${detail ? `: ${detail.slice(0, 240)}` : ''}`);
205
+ Object.assign(error, { status: response.status });
206
+ throw error;
207
+ }
208
+ return await response.json() as T;
209
+ }
210
+ }
211
+
212
+ function assertBatchNamespace(batch: ChangeBatch, namespace: string): void {
213
+ if (batch.namespace !== namespace) {
214
+ throw new Error('[memorix] sync batch namespace does not match the GitHub relay');
215
+ }
216
+ if (!isSafeSyncDeviceId(batch.deviceId)) {
217
+ throw new Error('[memorix] sync batch has an invalid device id');
218
+ }
219
+ }
220
+
221
+ function assertLimit(limit: number): void {
222
+ if (!Number.isSafeInteger(limit) || limit < 1) {
223
+ throw new Error('[memorix] sync pull limit must be a positive integer');
224
+ }
225
+ }
226
+
227
+ export function createGitHubRemote(
228
+ namespace: string,
229
+ env: NodeJS.ProcessEnv = process.env,
230
+ ): GitHubRemote {
231
+ const repo = required(env, 'MEMORIX_SYNC_GITHUB_REPO');
232
+ if (!/^[^/]+\/[^/]+$/.test(repo)) throw new Error('MEMORIX_SYNC_GITHUB_REPO must be owner/repository');
233
+ return new GitHubRemote({
234
+ namespace,
235
+ repo,
236
+ token: required(env, 'MEMORIX_SYNC_GITHUB_TOKEN'),
237
+ branch: env.MEMORIX_SYNC_GITHUB_BRANCH?.trim() || 'memorix-sync',
238
+ apiBaseUrl: env.MEMORIX_SYNC_GITHUB_API_URL?.trim() || 'https://api.github.com',
239
+ });
240
+ }
241
+
242
+ function required(env: NodeJS.ProcessEnv, name: string): string {
243
+ const value = env[name]?.trim();
244
+ if (!value) throw new Error(`[memorix] GitHub sync requires ${name}`);
245
+ return value;
246
+ }
247
+
248
+ function isStatus(error: unknown, status: number): boolean {
249
+ return typeof error === 'object' && error !== null && (error as { status?: unknown }).status === status;
250
+ }
251
+
252
+ function escapeRegExp(value: string): string {
253
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
254
+ }
255
+
256
+ function normalizeBase64(value: string | undefined): string {
257
+ return (value ?? '').replace(/\s/g, '');
258
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * S3-compatible object-store sync.
3
+ *
4
+ * Configuration is read from:
5
+ * - MEMORIX_SYNC_S3_BUCKET
6
+ * - MEMORIX_SYNC_S3_ENDPOINT
7
+ * - MEMORIX_SYNC_S3_ACCESS_KEY_ID
8
+ * - MEMORIX_SYNC_S3_SECRET_ACCESS_KEY
9
+ * - MEMORIX_SYNC_S3_REGION
10
+ * - MEMORIX_SYNC_S3_PREFIX (optional; key namespace so a shared bucket can
11
+ * host other data or several independent stores)
12
+ */
13
+
14
+ import type { ChangeBatch, SyncCompactReport, SyncPullPage, SyncRemote } from '../types.js';
15
+ import { isSafeSyncDeviceId } from '../namespace.js';
16
+
17
+ export interface ObjectStoreClient {
18
+ putIfAbsent(key: string, body: string): Promise<void>;
19
+ get(key: string): Promise<string | undefined>;
20
+ put(key: string, body: string): Promise<void>;
21
+ list(prefix: string, options?: { limit?: number; cursor?: string }): Promise<{ keys: string[]; nextCursor?: string }>;
22
+ delete(key: string): Promise<void>;
23
+ }
24
+
25
+ interface BatchObject {
26
+ key: string;
27
+ deviceId: string;
28
+ sequence: number;
29
+ }
30
+
31
+ const SEQUENCE_WIDTH = 20;
32
+ const BATCH_KEY = /^projects\/([^/]+)\/batches\/([^/]+)\/(\d+)\.jsonl$/;
33
+
34
+ export class ObjectStoreRemote implements SyncRemote {
35
+ readonly kind = 's3';
36
+ readonly namespace: string;
37
+ private readonly prefix: string;
38
+
39
+ constructor(private readonly client: ObjectStoreClient, namespace: string, prefix = '') {
40
+ this.namespace = namespace;
41
+ this.prefix = normalizePrefix(prefix);
42
+ }
43
+
44
+ async init(): Promise<void> {}
45
+
46
+ async push(batch: ChangeBatch): Promise<void> {
47
+ assertBatchNamespace(batch, this.namespace);
48
+ const sequence = String(batch.sequence).padStart(SEQUENCE_WIDTH, '0');
49
+ await this.client.putIfAbsent(
50
+ `${this.basePath}batches/${batch.deviceId}/${sequence}.jsonl`,
51
+ JSON.stringify(batch),
52
+ );
53
+ }
54
+
55
+ async pull(since: Record<string, number>, limit: number, pageToken?: string): Promise<SyncPullPage> {
56
+ assertLimit(limit);
57
+ const objects: BatchObject[] = [];
58
+ const page = await this.client.list(`${this.basePath}batches/`, { limit, cursor: pageToken });
59
+ for (const key of page.keys) {
60
+ const match = BATCH_KEY.exec(key.slice(this.prefix.length));
61
+ if (match === null) continue;
62
+ const [, namespace, deviceId, encodedSequence] = match;
63
+ if (namespace !== this.namespace) continue;
64
+ const sequence = Number(encodedSequence);
65
+ if (!Number.isSafeInteger(sequence) || sequence <= (since[deviceId] ?? 0)) continue;
66
+ objects.push({ key, deviceId, sequence });
67
+ }
68
+
69
+ objects.sort((left, right) => {
70
+ if (left.deviceId < right.deviceId) return -1;
71
+ if (left.deviceId > right.deviceId) return 1;
72
+ return left.sequence - right.sequence;
73
+ });
74
+
75
+ const batches: ChangeBatch[] = [];
76
+ for (const object of objects.slice(0, limit)) {
77
+ const body = await this.client.get(object.key);
78
+ if (body !== undefined) batches.push(JSON.parse(body) as ChangeBatch);
79
+ }
80
+ return { batches, hasMore: page.nextCursor !== undefined, nextPageToken: page.nextCursor };
81
+ }
82
+
83
+ async compact(through: Record<string, number>, options: { dryRun?: boolean } = {}): Promise<SyncCompactReport> {
84
+ let cursor: string | undefined;
85
+ const keys: string[] = [];
86
+ do {
87
+ const page = await this.client.list(`${this.basePath}batches/`, { limit: 1000, cursor });
88
+ for (const key of page.keys) {
89
+ const match = BATCH_KEY.exec(key.slice(this.prefix.length));
90
+ if (!match) continue;
91
+ const [, namespace, deviceId, encodedSequence] = match;
92
+ const sequence = Number(encodedSequence);
93
+ if (namespace === this.namespace && sequence <= (through[deviceId] ?? -1)) keys.push(key);
94
+ }
95
+ cursor = page.nextCursor;
96
+ } while (cursor !== undefined);
97
+ if (!options.dryRun) for (const key of keys) await this.client.delete(key);
98
+ return { candidates: keys.length, deleted: options.dryRun ? 0 : keys.length };
99
+ }
100
+
101
+ async close(): Promise<void> {}
102
+
103
+ private get basePath(): string {
104
+ return `${this.prefix}projects/${this.namespace}/`;
105
+ }
106
+ }
107
+
108
+ type S3Command = new (input: Record<string, unknown>) => unknown;
109
+
110
+ interface S3Response {
111
+ Body?: { transformToString(): Promise<string> };
112
+ Contents?: Array<{ Key?: string }>;
113
+ IsTruncated?: boolean;
114
+ NextContinuationToken?: string;
115
+ $metadata?: { httpStatusCode?: number };
116
+ }
117
+
118
+ interface S3Client {
119
+ send(command: unknown): Promise<S3Response>;
120
+ }
121
+
122
+ interface S3Module {
123
+ S3Client: new (config: Record<string, unknown>) => S3Client;
124
+ GetObjectCommand: S3Command;
125
+ ListObjectsV2Command: S3Command;
126
+ PutObjectCommand: S3Command;
127
+ }
128
+
129
+ export async function createS3ObjectStore(
130
+ env: NodeJS.ProcessEnv = process.env,
131
+ ): Promise<ObjectStoreClient> {
132
+ const bucket = requiredEnv(env, 'MEMORIX_SYNC_S3_BUCKET');
133
+ const endpoint = requiredEnv(env, 'MEMORIX_SYNC_S3_ENDPOINT');
134
+ const accessKeyId = requiredEnv(env, 'MEMORIX_SYNC_S3_ACCESS_KEY_ID');
135
+ const secretAccessKey = requiredEnv(env, 'MEMORIX_SYNC_S3_SECRET_ACCESS_KEY');
136
+ const region = requiredEnv(env, 'MEMORIX_SYNC_S3_REGION');
137
+
138
+ const moduleName = '@aws-sdk/client-s3';
139
+ // The SDK is optional by design, so a static import would make every install require it.
140
+ let sdk: S3Module;
141
+ try {
142
+ sdk = await import(moduleName) as unknown as S3Module;
143
+ } catch (cause) {
144
+ throw new Error(
145
+ 'S3 sync requires @aws-sdk/client-s3. Install it in the application using Memorix.',
146
+ { cause },
147
+ );
148
+ }
149
+
150
+ const s3 = new sdk.S3Client({
151
+ endpoint,
152
+ region,
153
+ credentials: { accessKeyId, secretAccessKey },
154
+ });
155
+
156
+ const client: ObjectStoreClient = {
157
+ async putIfAbsent(key, body) {
158
+ try {
159
+ await s3.send(new sdk.PutObjectCommand({
160
+ Bucket: bucket,
161
+ Key: key,
162
+ Body: body,
163
+ IfNoneMatch: '*',
164
+ }));
165
+ } catch (error) {
166
+ if (!isPreconditionFailure(error)) throw error;
167
+ const existing = await client.get(key);
168
+ if (existing !== body) {
169
+ throw new Error('[memorix] object-store relay key already contains a different payload');
170
+ }
171
+ }
172
+ },
173
+
174
+ async get(key) {
175
+ try {
176
+ const response = await s3.send(new sdk.GetObjectCommand({ Bucket: bucket, Key: key }));
177
+ return response.Body?.transformToString();
178
+ } catch (error) {
179
+ if (isNotFound(error)) return undefined;
180
+ throw error;
181
+ }
182
+ },
183
+
184
+ async put(key, body) {
185
+ await s3.send(new sdk.PutObjectCommand({ Bucket: bucket, Key: key, Body: body }));
186
+ },
187
+
188
+ async delete(key) {
189
+ const DeleteObjectCommand = (sdk as S3Module & { DeleteObjectCommand?: S3Command }).DeleteObjectCommand;
190
+ if (!DeleteObjectCommand) throw new Error('S3 sync requires DeleteObjectCommand support from @aws-sdk/client-s3');
191
+ await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
192
+ },
193
+
194
+ async list(prefix, options = {}) {
195
+ const keys: string[] = [];
196
+ const response = await s3.send(new sdk.ListObjectsV2Command({
197
+ Bucket: bucket,
198
+ Prefix: prefix,
199
+ MaxKeys: Math.max(1, Math.min(1000, options.limit ?? 1000)),
200
+ ContinuationToken: options.cursor,
201
+ }));
202
+ for (const object of response.Contents ?? []) {
203
+ if (object.Key !== undefined) keys.push(object.Key);
204
+ }
205
+ if (response.IsTruncated && !response.NextContinuationToken) {
206
+ throw new Error('[memorix] S3 sync list was truncated without a continuation token');
207
+ }
208
+ return { keys, nextCursor: response.IsTruncated ? response.NextContinuationToken : undefined };
209
+ },
210
+ };
211
+
212
+ return client;
213
+ }
214
+
215
+ function requiredEnv(env: NodeJS.ProcessEnv, name: string): string {
216
+ const value = env[name];
217
+ if (value === undefined || value.length === 0) {
218
+ throw new Error(`Missing required environment variable ${name}`);
219
+ }
220
+ return value;
221
+ }
222
+
223
+ function errorDetails(error: unknown): { name?: string; status?: number } {
224
+ if (typeof error !== 'object' || error === null) return {};
225
+ const candidate = error as { name?: unknown; $metadata?: { httpStatusCode?: unknown } };
226
+ return {
227
+ name: typeof candidate.name === 'string' ? candidate.name : undefined,
228
+ status: typeof candidate.$metadata?.httpStatusCode === 'number'
229
+ ? candidate.$metadata.httpStatusCode
230
+ : undefined,
231
+ };
232
+ }
233
+
234
+ function isPreconditionFailure(error: unknown): boolean {
235
+ const details = errorDetails(error);
236
+ return details.status === 412 || details.name === 'PreconditionFailed';
237
+ }
238
+
239
+ function isNotFound(error: unknown): boolean {
240
+ const details = errorDetails(error);
241
+ return details.status === 404 || details.name === 'NoSuchKey' || details.name === 'NotFound';
242
+ }
243
+
244
+ function assertBatchNamespace(batch: ChangeBatch, namespace: string): void {
245
+ if (batch.namespace !== namespace) {
246
+ throw new Error('[memorix] sync batch namespace does not match the object-store relay');
247
+ }
248
+ if (!isSafeSyncDeviceId(batch.deviceId)) {
249
+ throw new Error('[memorix] sync batch has an invalid device id');
250
+ }
251
+ }
252
+
253
+ function assertLimit(limit: number): void {
254
+ if (!Number.isSafeInteger(limit) || limit < 1) {
255
+ throw new Error('[memorix] sync pull limit must be a positive integer');
256
+ }
257
+ }
258
+
259
+ function normalizePrefix(value: string): string {
260
+ const trimmed = value.trim().replace(/^\/+|\/+$/g, '');
261
+ if (!trimmed) return '';
262
+ const segments = trimmed.split('/');
263
+ if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) {
264
+ throw new Error('[memorix] MEMORIX_SYNC_S3_PREFIX must be a relative key prefix');
265
+ }
266
+ return `${trimmed}/`;
267
+ }