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,179 @@
|
|
|
1
|
+
import { access, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { ChangeBatch, SyncCompactReport, SyncPullPage, SyncRemote } from '../types.js';
|
|
4
|
+
import { comparePageKey, decodePageCursor, encodePageCursor } from '../paging.js';
|
|
5
|
+
import { isSafeSyncDeviceId } from '../namespace.js';
|
|
6
|
+
|
|
7
|
+
export interface FsRemoteOptions {
|
|
8
|
+
root: string;
|
|
9
|
+
namespace: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class FsRemote implements SyncRemote {
|
|
13
|
+
readonly kind = 'fs';
|
|
14
|
+
readonly namespace: string;
|
|
15
|
+
|
|
16
|
+
constructor(private readonly options: FsRemoteOptions) {
|
|
17
|
+
this.namespace = options.namespace;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async init(options: { create?: boolean } = {}): Promise<void> {
|
|
21
|
+
if (options.create === false) return;
|
|
22
|
+
await Promise.all([
|
|
23
|
+
mkdir(this.batchesPath, { recursive: true }),
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async push(batch: ChangeBatch): Promise<void> {
|
|
28
|
+
assertBatchNamespace(batch, this.namespace);
|
|
29
|
+
const directory = path.join(this.batchesPath, batch.deviceId);
|
|
30
|
+
await mkdir(directory, { recursive: true });
|
|
31
|
+
const target = path.join(directory, `${String(batch.sequence).padStart(20, '0')}.jsonl`);
|
|
32
|
+
if (await exists(target)) {
|
|
33
|
+
await assertSameBatch(target, batch);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const lock = `${target}.lock`;
|
|
38
|
+
for (;;) {
|
|
39
|
+
try {
|
|
40
|
+
await writeFile(lock, '', { flag: 'wx' });
|
|
41
|
+
break;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
44
|
+
if (await exists(target)) return;
|
|
45
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
if (await exists(target)) await assertSameBatch(target, batch);
|
|
51
|
+
else await atomicWrite(target, batch);
|
|
52
|
+
} finally {
|
|
53
|
+
await rm(lock, { force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async pull(since: Record<string, number>, limit: number, pageToken?: string): Promise<SyncPullPage> {
|
|
58
|
+
assertLimit(limit);
|
|
59
|
+
const after = decodePageCursor(pageToken);
|
|
60
|
+
const objects: Array<{ path: string; deviceId: string; sequence: number }> = [];
|
|
61
|
+
let devices;
|
|
62
|
+
try {
|
|
63
|
+
devices = await readdir(this.batchesPath, { withFileTypes: true });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { batches: [], hasMore: false };
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const device of devices) {
|
|
70
|
+
if (!device.isDirectory()) continue;
|
|
71
|
+
const directory = path.join(this.batchesPath, device.name);
|
|
72
|
+
const files = await readdir(directory, { withFileTypes: true });
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
if (!file.isFile() || !/^\d+\.jsonl$/.test(file.name)) continue;
|
|
75
|
+
const sequence = Number.parseInt(file.name, 10);
|
|
76
|
+
if (!Number.isSafeInteger(sequence) || sequence < 1) continue;
|
|
77
|
+
if (sequence <= (since[device.name] ?? 0)) continue;
|
|
78
|
+
if (after && comparePageKey({ deviceId: device.name, sequence }, after) <= 0) continue;
|
|
79
|
+
objects.push({ path: path.join(directory, file.name), deviceId: device.name, sequence });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sorted = objects.sort((left, right) => {
|
|
84
|
+
if (left.deviceId < right.deviceId) return -1;
|
|
85
|
+
if (left.deviceId > right.deviceId) return 1;
|
|
86
|
+
return left.sequence - right.sequence;
|
|
87
|
+
});
|
|
88
|
+
const batches: ChangeBatch[] = [];
|
|
89
|
+
const pageObjects = sorted.slice(0, limit);
|
|
90
|
+
for (const object of pageObjects) {
|
|
91
|
+
const raw = await readFile(object.path, 'utf8');
|
|
92
|
+
for (const line of raw.split(/\r?\n/).filter(Boolean)) batches.push(JSON.parse(line) as ChangeBatch);
|
|
93
|
+
}
|
|
94
|
+
const hasMore = sorted.length > pageObjects.length;
|
|
95
|
+
return {
|
|
96
|
+
batches,
|
|
97
|
+
hasMore,
|
|
98
|
+
nextPageToken: hasMore && pageObjects.length > 0
|
|
99
|
+
? encodePageCursor(pageObjects[pageObjects.length - 1])
|
|
100
|
+
: undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async compact(through: Record<string, number>, options: { dryRun?: boolean } = {}): Promise<SyncCompactReport> {
|
|
105
|
+
let candidates = 0;
|
|
106
|
+
const files: string[] = [];
|
|
107
|
+
let devices: Array<import('node:fs').Dirent>;
|
|
108
|
+
try {
|
|
109
|
+
devices = await readdir(this.batchesPath, { withFileTypes: true });
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { candidates: 0, deleted: 0 };
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
for (const device of devices) {
|
|
115
|
+
if (!device.isDirectory()) continue;
|
|
116
|
+
const cutoff = through[device.name];
|
|
117
|
+
if (!Number.isSafeInteger(cutoff)) continue;
|
|
118
|
+
const directory = path.join(this.batchesPath, device.name);
|
|
119
|
+
for (const file of await readdir(directory, { withFileTypes: true })) {
|
|
120
|
+
if (!file.isFile() || !/^\d+\.jsonl$/.test(file.name)) continue;
|
|
121
|
+
const sequence = Number.parseInt(file.name, 10);
|
|
122
|
+
if (sequence <= cutoff) {
|
|
123
|
+
candidates++;
|
|
124
|
+
files.push(path.join(directory, file.name));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!options.dryRun) for (const file of files) await rm(file, { force: true });
|
|
129
|
+
return { candidates, deleted: options.dryRun ? 0 : files.length };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async close(): Promise<void> {}
|
|
133
|
+
|
|
134
|
+
private get batchesPath(): string {
|
|
135
|
+
return path.join(this.options.root, 'projects', this.options.namespace, 'batches');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function assertBatchNamespace(batch: ChangeBatch, namespace: string): void {
|
|
140
|
+
if (batch.namespace !== namespace) {
|
|
141
|
+
throw new Error('[memorix] sync batch namespace does not match the filesystem relay');
|
|
142
|
+
}
|
|
143
|
+
if (!isSafeSyncDeviceId(batch.deviceId)) {
|
|
144
|
+
throw new Error('[memorix] sync batch has an invalid device id');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function assertLimit(limit: number): void {
|
|
149
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
150
|
+
throw new Error('[memorix] sync pull limit must be a positive integer');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function exists(file: string): Promise<boolean> {
|
|
155
|
+
try {
|
|
156
|
+
await access(file);
|
|
157
|
+
return true;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function atomicWrite(file: string, value: unknown): Promise<void> {
|
|
165
|
+
const temporary = `${file}.${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`;
|
|
166
|
+
try {
|
|
167
|
+
await writeFile(temporary, JSON.stringify(value), { encoding: 'utf8', flag: 'wx' });
|
|
168
|
+
await rename(temporary, file);
|
|
169
|
+
} finally {
|
|
170
|
+
await rm(temporary, { force: true });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function assertSameBatch(file: string, expected: ChangeBatch): Promise<void> {
|
|
175
|
+
const actual = JSON.parse(await readFile(file, 'utf8')) as ChangeBatch;
|
|
176
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
177
|
+
throw new Error('[memorix] filesystem relay event path already contains a different payload');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -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
|
+
}
|