@prjct.app/pi-team 0.6.1 → 0.7.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 +65 -0
- package/CONTRIBUTING.md +2 -1
- package/README.md +23 -178
- package/docs/architecture.md +36 -173
- package/package.json +10 -4
- package/src/commands/team-command.ts +37 -0
- package/src/domain/lease.ts +54 -0
- package/src/domain/member.ts +58 -0
- package/src/domain/message.ts +91 -0
- package/src/domain/request.ts +67 -0
- package/src/domain/team.ts +71 -0
- package/src/dynamic/domain.ts +110 -0
- package/src/dynamic/memory.ts +38 -0
- package/src/dynamic/panel.ts +155 -0
- package/src/dynamic/peer-log.ts +39 -0
- package/src/dynamic/runner.ts +196 -0
- package/src/dynamic/service.ts +292 -0
- package/src/dynamic/store.ts +57 -0
- package/src/dynamic/view.ts +21 -0
- package/src/dynamic/worker.ts +210 -0
- package/src/dynamic/workspace.ts +43 -0
- package/src/index.ts +204 -679
- package/src/process-identity.ts +68 -0
- package/src/runtime/delivery.ts +326 -0
- package/src/runtime/membership.ts +212 -0
- package/src/runtime/presence.ts +98 -0
- package/src/runtime/purge.ts +39 -0
- package/src/runtime/reconciler.ts +112 -0
- package/src/runtime/requests.ts +353 -0
- package/src/runtime/resources.ts +117 -0
- package/src/runtime/team-runtime.ts +47 -0
- package/src/runtime/team-tool.ts +191 -0
- package/src/storage/atomic.ts +347 -0
- package/src/storage/inbox-store.ts +290 -0
- package/src/storage/lease-store.ts +158 -0
- package/src/storage/paths.ts +76 -0
- package/src/storage/receipt-store.ts +117 -0
- package/src/storage/team-store.ts +190 -0
- package/src/supervisor/control-protocol.ts +125 -0
- package/src/supervisor/runtime-store.ts +231 -0
- package/src/supervisor/shutdown.ts +141 -0
- package/src/supervisor/supervisor.ts +657 -0
- package/src/supervisor/tmux-adapter.ts +192 -0
- package/src/supervisor/worker-bootstrap.ts +43 -0
- package/src/supervisor/worker-client.ts +233 -0
- package/src/ui/team-dashboard.ts +179 -0
- package/src/mailbox.ts +0 -536
- package/src/schema.ts +0 -25
- package/src/store.ts +0 -247
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { link, lstat, mkdir, open, readdir, rename, unlink } from 'node:fs/promises';
|
|
4
|
+
import { basename, dirname, join, relative, sep } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_MAX_RECORD_BYTES = 256 * 1024;
|
|
7
|
+
const LOCK_ATTEMPTS = Array.from({ length: 200 }, (_, index) => index);
|
|
8
|
+
|
|
9
|
+
export type RecordValidator<T> = (value: unknown) => asserts value is T;
|
|
10
|
+
export type AtomicWriteOptions = {
|
|
11
|
+
readonly maxBytes?: number;
|
|
12
|
+
readonly previous?: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type LockOptions = {
|
|
15
|
+
readonly staleMs?: number;
|
|
16
|
+
readonly retryMs?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function storageError(message: string, code: string): Error {
|
|
20
|
+
return Object.assign(new Error(message), { code });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertPrivateMode(path: string, info: { mode: number; uid: number }): void {
|
|
24
|
+
if ((info.mode & 0o077) !== 0 || (process.getuid && info.uid !== process.getuid())) {
|
|
25
|
+
throw storageError(`Unsafe storage permissions or owner: ${path}.`, 'UNSAFE_STORAGE');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function syncDirectory(path: string): Promise<void> {
|
|
30
|
+
if (process.platform === 'win32') return;
|
|
31
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0));
|
|
32
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function syncCreatedDirectories(firstCreated: string, target: string): Promise<void> {
|
|
36
|
+
const suffix = relative(firstCreated, target).split(sep).filter(Boolean);
|
|
37
|
+
const created = [firstCreated, ...suffix.map((_, index) => join(firstCreated, ...suffix.slice(0, index + 1)))];
|
|
38
|
+
for (const path of created) await syncDirectory(dirname(path));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// mkdir is not a durable publication by itself. Sync every parent that gained
|
|
42
|
+
// a directory entry, from the first recursively created component to the leaf.
|
|
43
|
+
async function createDirectories(path: string, mode: number): Promise<void> {
|
|
44
|
+
const firstCreated = await mkdir(path, { recursive: true, mode });
|
|
45
|
+
if (firstCreated) await syncCreatedDirectories(firstCreated, path);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function ensurePrivateDirectory(path: string, create = true): Promise<void> {
|
|
49
|
+
if (create) await createDirectories(path, 0o700);
|
|
50
|
+
const info = await lstat(path);
|
|
51
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
52
|
+
throw storageError(`Unsafe storage directory: ${path}.`, 'UNSAFE_STORAGE');
|
|
53
|
+
}
|
|
54
|
+
assertPrivateMode(path, info);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function nearestExistingDirectory(path: string): Promise<string> {
|
|
58
|
+
const info = await lstat(path).catch(error => {
|
|
59
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
60
|
+
throw error;
|
|
61
|
+
});
|
|
62
|
+
if (info) {
|
|
63
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
64
|
+
throw storageError(`Unsafe storage ancestor: ${path}.`, 'UNSAFE_STORAGE');
|
|
65
|
+
}
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
const parent = dirname(path);
|
|
69
|
+
if (parent === path) throw storageError(`No existing storage ancestor for ${path}.`, 'UNSAFE_STORAGE');
|
|
70
|
+
return nearestExistingDirectory(parent);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function ensureRootParent(root: string): Promise<void> {
|
|
74
|
+
const parent = dirname(root);
|
|
75
|
+
const existing = await nearestExistingDirectory(parent);
|
|
76
|
+
await createDirectories(parent, 0o700);
|
|
77
|
+
const suffix = relative(existing, parent).split(sep).filter(Boolean);
|
|
78
|
+
const chain = [existing, ...suffix.map((_, index) => join(existing, ...suffix.slice(0, index + 1)))];
|
|
79
|
+
for (const path of chain) {
|
|
80
|
+
const info = await lstat(path);
|
|
81
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
82
|
+
throw storageError(`Unsafe storage ancestor: ${path}.`, 'UNSAFE_STORAGE');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function ensurePrivateTree(root: string, ...segments: readonly string[]): Promise<void> {
|
|
88
|
+
await ensureRootParent(root);
|
|
89
|
+
await ensurePrivateDirectory(root);
|
|
90
|
+
const paths = segments.map((_, index) => join(root, ...segments.slice(0, index + 1)));
|
|
91
|
+
for (const path of paths) await ensurePrivateDirectory(path);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function checkSize(text: string, maxBytes: number): void {
|
|
95
|
+
if (Buffer.byteLength(text, 'utf8') > maxBytes) {
|
|
96
|
+
throw storageError(`Record exceeds ${maxBytes} bytes.`, 'RECORD_TOO_LARGE');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function readText(path: string, maxBytes: number): Promise<string | undefined> {
|
|
101
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)).catch(error => {
|
|
102
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
103
|
+
throw error;
|
|
104
|
+
});
|
|
105
|
+
if (!handle) return undefined;
|
|
106
|
+
try {
|
|
107
|
+
const info = await handle.stat();
|
|
108
|
+
if (!info.isFile()) throw storageError(`Unsafe storage file: ${path}.`, 'UNSAFE_STORAGE');
|
|
109
|
+
assertPrivateMode(path, info);
|
|
110
|
+
if (info.size > maxBytes) throw storageError(`Record exceeds ${maxBytes} bytes.`, 'RECORD_TOO_LARGE');
|
|
111
|
+
return await handle.readFile('utf8');
|
|
112
|
+
} finally { await handle.close(); }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function readJson<T>(path: string, validate: RecordValidator<T>, maxBytes = DEFAULT_MAX_RECORD_BYTES): Promise<T | undefined> {
|
|
116
|
+
const text = await readText(path, maxBytes);
|
|
117
|
+
if (text === undefined) return undefined;
|
|
118
|
+
const parsed = (() => {
|
|
119
|
+
try { return JSON.parse(text) as unknown; }
|
|
120
|
+
catch { throw storageError(`Corrupt JSON record preserved at ${path}.`, 'CORRUPT_RECORD'); }
|
|
121
|
+
})();
|
|
122
|
+
try { validate(parsed); }
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw Object.assign(new Error(`Invalid record preserved at ${path}: ${(error as Error).message}`), { code: 'CORRUPT_RECORD' });
|
|
125
|
+
}
|
|
126
|
+
return parsed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function writeTemporary(path: string, text: string, maxBytes: number): Promise<string> {
|
|
130
|
+
checkSize(text, maxBytes);
|
|
131
|
+
await ensurePrivateDirectory(dirname(path));
|
|
132
|
+
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
133
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
134
|
+
try {
|
|
135
|
+
await handle.writeFile(text, 'utf8');
|
|
136
|
+
await handle.sync();
|
|
137
|
+
} catch (error) {
|
|
138
|
+
await handle.close().catch(() => {});
|
|
139
|
+
await unlink(temporary).catch(() => {});
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
await handle.close();
|
|
143
|
+
return temporary;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function createAtomicJson(path: string, value: unknown, maxBytes = DEFAULT_MAX_RECORD_BYTES): Promise<void> {
|
|
147
|
+
const temporary = await writeTemporary(path, `${JSON.stringify(value)}\n`, maxBytes);
|
|
148
|
+
try {
|
|
149
|
+
await link(temporary, path);
|
|
150
|
+
await syncDirectory(dirname(path));
|
|
151
|
+
} finally { await unlink(temporary).catch(() => {}); }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function replaceAtomicJson(path: string, value: unknown, options: AtomicWriteOptions = {}): Promise<void> {
|
|
155
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_RECORD_BYTES;
|
|
156
|
+
const text = `${JSON.stringify(value)}\n`;
|
|
157
|
+
checkSize(text, maxBytes);
|
|
158
|
+
if (options.previous) {
|
|
159
|
+
const current = await readText(path, maxBytes);
|
|
160
|
+
if (current !== undefined) await replaceAtomicText(`${path}.previous`, current, maxBytes);
|
|
161
|
+
}
|
|
162
|
+
await replaceAtomicText(path, text, maxBytes);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function replaceAtomicText(path: string, text: string, maxBytes: number): Promise<void> {
|
|
166
|
+
const temporary = await writeTemporary(path, text, maxBytes);
|
|
167
|
+
try {
|
|
168
|
+
await rename(temporary, path);
|
|
169
|
+
await syncDirectory(dirname(path));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
await unlink(temporary).catch(() => {});
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function moveAtomic(from: string, to: string): Promise<void> {
|
|
177
|
+
await ensurePrivateDirectory(dirname(to));
|
|
178
|
+
await rename(from, to);
|
|
179
|
+
await syncDirectory(dirname(to));
|
|
180
|
+
if (dirname(from) !== dirname(to)) await syncDirectory(dirname(from));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function removeAtomic(path: string): Promise<boolean> {
|
|
184
|
+
const removed = await unlink(path).then(() => true, error => {
|
|
185
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
|
186
|
+
throw error;
|
|
187
|
+
});
|
|
188
|
+
if (removed) await syncDirectory(dirname(path));
|
|
189
|
+
return removed;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function jsonFileNames(path: string, create = true): Promise<string[]> {
|
|
193
|
+
try { await ensurePrivateDirectory(path, create); }
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (!create && (error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
199
|
+
return entries
|
|
200
|
+
.filter(entry => entry.isFile() && !entry.isSymbolicLink() && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.json$/.test(entry.name))
|
|
201
|
+
.map(entry => entry.name.slice(0, -'.json'.length))
|
|
202
|
+
.sort();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function processAlive(pid: number): Promise<boolean> {
|
|
206
|
+
try { process.kill(pid, 0); return true; }
|
|
207
|
+
catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH'; }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function staleLock(path: string, staleMs: number): Promise<boolean> {
|
|
211
|
+
const info = await lstat(path).catch(error => {
|
|
212
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
213
|
+
throw error;
|
|
214
|
+
});
|
|
215
|
+
if (!info) return false;
|
|
216
|
+
if (!info.isFile() || info.isSymbolicLink()) throw storageError(`Unsafe lock file: ${path}.`, 'UNSAFE_STORAGE');
|
|
217
|
+
assertPrivateMode(path, info);
|
|
218
|
+
if (Date.now() - info.mtimeMs <= staleMs) return false;
|
|
219
|
+
const text = await readText(path, 4096);
|
|
220
|
+
const pid = (() => {
|
|
221
|
+
try {
|
|
222
|
+
const parsed = JSON.parse(text ?? '') as { pid?: unknown };
|
|
223
|
+
return typeof parsed.pid === 'number' && Number.isSafeInteger(parsed.pid) && parsed.pid > 0 ? parsed.pid : undefined;
|
|
224
|
+
} catch { return undefined; }
|
|
225
|
+
})();
|
|
226
|
+
return pid === undefined || !await processAlive(pid);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function tryAcquireLock(path: string): Promise<{ readonly handle: Awaited<ReturnType<typeof open>>; readonly token: string } | undefined> {
|
|
230
|
+
const token = randomUUID();
|
|
231
|
+
const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600)
|
|
232
|
+
.catch(error => {
|
|
233
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return undefined;
|
|
234
|
+
throw error;
|
|
235
|
+
});
|
|
236
|
+
if (!handle) return undefined;
|
|
237
|
+
try {
|
|
238
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }), 'utf8');
|
|
239
|
+
return { handle, token };
|
|
240
|
+
} catch (error) {
|
|
241
|
+
await handle.close().catch(() => {});
|
|
242
|
+
await unlink(path).catch(() => {});
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function releaseLock(path: string, token: string): Promise<void> {
|
|
248
|
+
const text = await readText(path, 4096).catch(() => undefined);
|
|
249
|
+
const currentToken = (() => {
|
|
250
|
+
try { return (JSON.parse(text ?? '') as { token?: unknown }).token; }
|
|
251
|
+
catch { return undefined; }
|
|
252
|
+
})();
|
|
253
|
+
if (currentToken === token) await unlink(path).catch(() => {});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
type GateResult<T> = { readonly entered: false } | { readonly entered: true; readonly value: T };
|
|
257
|
+
|
|
258
|
+
// Main-lock replacement and release run only while this short-lived gate is
|
|
259
|
+
// owned. Stale gates are reclaimed only for dead holders and only while their
|
|
260
|
+
// inode still matches the inspected handle; a changed pathname is left alone.
|
|
261
|
+
async function reclaimStaleGate(path: string, staleMs: number): Promise<boolean> {
|
|
262
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)).catch(error => {
|
|
263
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
264
|
+
throw error;
|
|
265
|
+
});
|
|
266
|
+
if (!handle) return false;
|
|
267
|
+
try {
|
|
268
|
+
const info = await handle.stat();
|
|
269
|
+
if (!info.isFile()) throw storageError(`Unsafe lock gate: ${path}.`, 'UNSAFE_STORAGE');
|
|
270
|
+
assertPrivateMode(path, info);
|
|
271
|
+
if (Date.now() - info.mtimeMs <= staleMs) return false;
|
|
272
|
+
const text = await handle.readFile('utf8');
|
|
273
|
+
const parsed = (() => {
|
|
274
|
+
try { return JSON.parse(text) as { pid?: unknown }; }
|
|
275
|
+
catch { return {} as { pid?: unknown }; }
|
|
276
|
+
})();
|
|
277
|
+
const pid = typeof parsed.pid === 'number' && Number.isSafeInteger(parsed.pid) && parsed.pid > 0 ? parsed.pid : undefined;
|
|
278
|
+
if (pid !== undefined && await processAlive(pid)) return false;
|
|
279
|
+
const current = await lstat(path).catch(() => undefined);
|
|
280
|
+
if (!current || current.dev !== info.dev || current.ino !== info.ino) return false;
|
|
281
|
+
await unlink(path);
|
|
282
|
+
return true;
|
|
283
|
+
} finally { await handle.close(); }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function underLockGate<T>(path: string, staleMs: number, action: () => Promise<T>): Promise<GateResult<T>> {
|
|
287
|
+
const gatePath = `${path}.gate`;
|
|
288
|
+
const token = randomUUID();
|
|
289
|
+
const gate = await open(
|
|
290
|
+
gatePath,
|
|
291
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0),
|
|
292
|
+
0o600,
|
|
293
|
+
).catch(async error => {
|
|
294
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
295
|
+
await reclaimStaleGate(gatePath, staleMs);
|
|
296
|
+
return undefined;
|
|
297
|
+
});
|
|
298
|
+
if (!gate) return { entered: false };
|
|
299
|
+
try {
|
|
300
|
+
await gate.writeFile(JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }), 'utf8');
|
|
301
|
+
return { entered: true, value: await action() };
|
|
302
|
+
} finally {
|
|
303
|
+
await gate.close();
|
|
304
|
+
await releaseLock(gatePath, token);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function claimLock(path: string, staleMs: number): Promise<GateResult<Awaited<ReturnType<typeof tryAcquireLock>>>> {
|
|
309
|
+
return underLockGate(path, staleMs, async () => {
|
|
310
|
+
const current = await tryAcquireLock(path);
|
|
311
|
+
if (current) return current;
|
|
312
|
+
if (!await staleLock(path, staleMs)) return undefined;
|
|
313
|
+
await unlink(path);
|
|
314
|
+
return tryAcquireLock(path);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function releaseOwnedLock(path: string, token: string, retryMs: number, staleMs: number): Promise<void> {
|
|
319
|
+
for (const attempt of LOCK_ATTEMPTS) {
|
|
320
|
+
const released = await underLockGate(path, staleMs, () => releaseLock(path, token));
|
|
321
|
+
if (released.entered) return;
|
|
322
|
+
await new Promise(resolve => setTimeout(resolve, retryMs + Math.min(attempt, 20)));
|
|
323
|
+
}
|
|
324
|
+
throw storageError(`Storage lock gate is busy: ${path}.`, 'STORAGE_BUSY');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function withStorageLock<T>(path: string, action: () => Promise<T>, options: LockOptions = {}): Promise<T> {
|
|
328
|
+
const staleMs = options.staleMs ?? 30_000;
|
|
329
|
+
const retryMs = options.retryMs ?? 5;
|
|
330
|
+
if (!Number.isFinite(staleMs) || staleMs < 0 || !Number.isFinite(retryMs) || retryMs <= 0) {
|
|
331
|
+
throw new Error('Invalid storage lock timing.');
|
|
332
|
+
}
|
|
333
|
+
await ensurePrivateDirectory(dirname(path));
|
|
334
|
+
for (const attempt of LOCK_ATTEMPTS) {
|
|
335
|
+
const claim = await claimLock(path, staleMs);
|
|
336
|
+
const owned = claim.entered ? claim.value : undefined;
|
|
337
|
+
if (owned) {
|
|
338
|
+
try { return await action(); }
|
|
339
|
+
finally {
|
|
340
|
+
await owned.handle.close();
|
|
341
|
+
await releaseOwnedLock(path, owned.token, retryMs, staleMs);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
await new Promise(resolve => setTimeout(resolve, retryMs + Math.min(attempt, 20)));
|
|
345
|
+
}
|
|
346
|
+
throw storageError(`Storage lock is busy: ${path}.`, 'STORAGE_BUSY');
|
|
347
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { assertMember, type Member } from '../domain/member.ts';
|
|
3
|
+
import { assertEnvelope, messageExpired, type Envelope } from '../domain/message.ts';
|
|
4
|
+
import { assertTeam, assertEntityId, assertTeamId } from '../domain/team.ts';
|
|
5
|
+
import {
|
|
6
|
+
createAtomicJson, ensurePrivateDirectory, ensurePrivateTree, jsonFileNames, moveAtomic, readJson, removeAtomic,
|
|
7
|
+
withStorageLock,
|
|
8
|
+
} from './atomic.ts';
|
|
9
|
+
import { TeamPaths } from './paths.ts';
|
|
10
|
+
|
|
11
|
+
// JSON escaping can expand a valid 8 KiB control-character body to six bytes
|
|
12
|
+
// per input byte, so the record cap must bound metadata without rejecting it.
|
|
13
|
+
const MESSAGE_MAX_BYTES = 64 * 1024;
|
|
14
|
+
const TEAM_MAX_BYTES = 32 * 1024;
|
|
15
|
+
const MEMBER_MAX_BYTES = 64 * 1024;
|
|
16
|
+
|
|
17
|
+
export type InboxStoreOptions = {
|
|
18
|
+
readonly recipientQuota?: number;
|
|
19
|
+
readonly teamQuota?: number;
|
|
20
|
+
readonly now?: () => number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type InboxPage = {
|
|
24
|
+
readonly messages: readonly Envelope[];
|
|
25
|
+
readonly nextCursor?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class InboxStore {
|
|
29
|
+
private readonly recipientQuota: number;
|
|
30
|
+
private readonly teamQuota: number;
|
|
31
|
+
private readonly now: () => number;
|
|
32
|
+
|
|
33
|
+
constructor(readonly paths: TeamPaths, options: InboxStoreOptions = {}) {
|
|
34
|
+
this.recipientQuota = options.recipientQuota ?? 100;
|
|
35
|
+
this.teamQuota = options.teamQuota ?? 1_000;
|
|
36
|
+
this.now = options.now ?? Date.now;
|
|
37
|
+
if (!Number.isSafeInteger(this.recipientQuota) || this.recipientQuota < 1 ||
|
|
38
|
+
!Number.isSafeInteger(this.teamQuota) || this.teamQuota < this.recipientQuota) {
|
|
39
|
+
throw new Error('Invalid inbox quotas.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async prepare(): Promise<void> {
|
|
44
|
+
await ensurePrivateTree(this.paths.root, 'teams');
|
|
45
|
+
await ensurePrivateTree(this.paths.root, 'control');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async requireTeam(teamId: string, requireOpen = false): Promise<void> {
|
|
49
|
+
try {
|
|
50
|
+
await ensurePrivateDirectory(this.paths.team(teamId), false);
|
|
51
|
+
await ensurePrivateDirectory(this.paths.members(teamId), false);
|
|
52
|
+
await ensurePrivateDirectory(this.paths.inbox(teamId), false);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
55
|
+
throw Object.assign(new Error(`Unknown team "${teamId}".`), { code: 'NOT_FOUND' });
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
const team = await readJson(this.paths.teamRecord(teamId), assertTeam, TEAM_MAX_BYTES);
|
|
60
|
+
if (!team) throw Object.assign(new Error(`Team "${teamId}" is incomplete.`), { code: 'NOT_FOUND' });
|
|
61
|
+
if (requireOpen && team.state !== 'open') {
|
|
62
|
+
throw Object.assign(new Error(`Team "${teamId}" is ${team.state}.`), { code: 'TEAM_CLOSED' });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async member(teamId: string, memberId: string): Promise<Member> {
|
|
67
|
+
await ensurePrivateDirectory(this.paths.members(teamId), false);
|
|
68
|
+
const member = await readJson(this.paths.member(teamId, memberId), assertMember, MEMBER_MAX_BYTES);
|
|
69
|
+
if (!member || member.teamId !== teamId || member.memberId !== memberId) {
|
|
70
|
+
throw Object.assign(new Error(`Unknown member "${memberId}".`), { code: 'NOT_FOUND' });
|
|
71
|
+
}
|
|
72
|
+
return member;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async prepareRecipient(teamId: string, memberId: string, create: boolean): Promise<boolean> {
|
|
76
|
+
try {
|
|
77
|
+
await ensurePrivateDirectory(this.paths.inbox(teamId), false);
|
|
78
|
+
await ensurePrivateDirectory(this.paths.memberInbox(teamId, memberId), create);
|
|
79
|
+
await ensurePrivateDirectory(this.paths.pending(teamId, memberId), create);
|
|
80
|
+
await ensurePrivateDirectory(this.paths.claimed(teamId, memberId), create);
|
|
81
|
+
return true;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (!create && (error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async memberCount(teamId: string, memberId: string): Promise<number> {
|
|
89
|
+
if (!await this.prepareRecipient(teamId, memberId, false)) return 0;
|
|
90
|
+
const [pending, claimed] = await Promise.all([
|
|
91
|
+
jsonFileNames(this.paths.pending(teamId, memberId), false),
|
|
92
|
+
jsonFileNames(this.paths.claimed(teamId, memberId), false),
|
|
93
|
+
]);
|
|
94
|
+
return pending.length + claimed.length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async inboxMemberIds(teamId: string): Promise<string[]> {
|
|
98
|
+
const entries = await readdir(this.paths.inbox(teamId), { withFileTypes: true }).catch(error => {
|
|
99
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
100
|
+
throw error;
|
|
101
|
+
});
|
|
102
|
+
return entries
|
|
103
|
+
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
|
|
104
|
+
.map(entry => entry.name)
|
|
105
|
+
.filter(memberId => {
|
|
106
|
+
try { assertEntityId(memberId, 'member ID'); return true; }
|
|
107
|
+
catch { return false; }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async teamCount(teamId: string): Promise<number> {
|
|
112
|
+
const counts = await Promise.all((await this.inboxMemberIds(teamId)).map(memberId => this.memberCount(teamId, memberId)));
|
|
113
|
+
return counts.reduce((sum, count) => sum + count, 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async teamHasMessage(teamId: string, messageId: string): Promise<boolean> {
|
|
117
|
+
const matches = await Promise.all((await this.inboxMemberIds(teamId)).map(async memberId => {
|
|
118
|
+
if (!await this.prepareRecipient(teamId, memberId, false)) return false;
|
|
119
|
+
const [pending, claimed] = await Promise.all([
|
|
120
|
+
jsonFileNames(this.paths.pending(teamId, memberId), false),
|
|
121
|
+
jsonFileNames(this.paths.claimed(teamId, memberId), false),
|
|
122
|
+
]);
|
|
123
|
+
return pending.includes(messageId) || claimed.includes(messageId);
|
|
124
|
+
}));
|
|
125
|
+
return matches.some(Boolean);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private assertStoredMessage(message: Envelope, teamId: string, recipientId: string, messageId?: string): void {
|
|
129
|
+
if (message.teamId !== teamId || message.toMemberId !== recipientId || (messageId && message.messageId !== messageId)) {
|
|
130
|
+
throw Object.assign(new Error('Message identity does not match its storage path.'), { code: 'CORRUPT_RECORD' });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async enqueue(message: Envelope): Promise<void> {
|
|
135
|
+
assertEnvelope(message);
|
|
136
|
+
await this.prepare();
|
|
137
|
+
await withStorageLock(this.paths.inboxLock(message.teamId), async () => {
|
|
138
|
+
await this.requireTeam(message.teamId, true);
|
|
139
|
+
if (messageExpired(message, this.now())) throw Object.assign(new Error('Message has already expired.'), { code: 'EXPIRED' });
|
|
140
|
+
const [sender, recipient] = await Promise.all([
|
|
141
|
+
this.member(message.teamId, message.fromMemberId),
|
|
142
|
+
this.member(message.teamId, message.toMemberId),
|
|
143
|
+
]);
|
|
144
|
+
if (sender.generation !== message.senderGeneration ||
|
|
145
|
+
(message.recipientGeneration !== undefined && recipient.generation !== message.recipientGeneration)) {
|
|
146
|
+
throw Object.assign(new Error('Message generation has been fenced.'), { code: 'FENCED' });
|
|
147
|
+
}
|
|
148
|
+
const [recipientCount, teamCount, duplicate] = await Promise.all([
|
|
149
|
+
this.memberCount(message.teamId, message.toMemberId),
|
|
150
|
+
this.teamCount(message.teamId),
|
|
151
|
+
this.teamHasMessage(message.teamId, message.messageId),
|
|
152
|
+
]);
|
|
153
|
+
if (duplicate) throw Object.assign(new Error(`Message "${message.messageId}" already exists.`), { code: 'ALREADY_EXISTS' });
|
|
154
|
+
if (recipientCount >= this.recipientQuota) {
|
|
155
|
+
throw Object.assign(new Error(`Recipient inbox quota reached (${this.recipientQuota}).`), { code: 'QUOTA_EXCEEDED' });
|
|
156
|
+
}
|
|
157
|
+
if (teamCount >= this.teamQuota) {
|
|
158
|
+
throw Object.assign(new Error(`Team inbox quota reached (${this.teamQuota}).`), { code: 'QUOTA_EXCEEDED' });
|
|
159
|
+
}
|
|
160
|
+
await this.prepareRecipient(message.teamId, message.toMemberId, true);
|
|
161
|
+
await createAtomicJson(
|
|
162
|
+
this.paths.pendingMessage(message.teamId, message.toMemberId, message.messageId),
|
|
163
|
+
message,
|
|
164
|
+
MESSAGE_MAX_BYTES,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async readPending(teamId: string, recipientId: string, messageId: string): Promise<Envelope | undefined> {
|
|
170
|
+
await this.prepare();
|
|
171
|
+
await this.requireTeam(teamId);
|
|
172
|
+
if (!await this.prepareRecipient(teamId, recipientId, false)) return undefined;
|
|
173
|
+
const message = await readJson(this.paths.pendingMessage(teamId, recipientId, messageId), assertEnvelope, MESSAGE_MAX_BYTES);
|
|
174
|
+
if (message) this.assertStoredMessage(message, teamId, recipientId, messageId);
|
|
175
|
+
return message;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async readClaimed(teamId: string, recipientId: string, messageId: string): Promise<Envelope | undefined> {
|
|
179
|
+
await this.prepare();
|
|
180
|
+
await this.requireTeam(teamId);
|
|
181
|
+
if (!await this.prepareRecipient(teamId, recipientId, false)) return undefined;
|
|
182
|
+
const message = await readJson(this.paths.claimedMessage(teamId, recipientId, messageId), assertEnvelope, MESSAGE_MAX_BYTES);
|
|
183
|
+
if (message) this.assertStoredMessage(message, teamId, recipientId, messageId);
|
|
184
|
+
return message;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async listPending(teamId: string, recipientId: string, limit = 50, cursor?: string): Promise<InboxPage> {
|
|
188
|
+
assertTeamId(teamId);
|
|
189
|
+
assertEntityId(recipientId, 'recipient ID');
|
|
190
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('Inbox page limit must be between 1 and 100.');
|
|
191
|
+
if (cursor !== undefined) assertEntityId(cursor, 'inbox cursor');
|
|
192
|
+
await this.prepare();
|
|
193
|
+
await this.requireTeam(teamId);
|
|
194
|
+
if (!await this.prepareRecipient(teamId, recipientId, false)) return { messages: [] };
|
|
195
|
+
const ids = await jsonFileNames(this.paths.pending(teamId, recipientId), false);
|
|
196
|
+
const remaining = cursor === undefined ? ids : ids.filter(id => id > cursor);
|
|
197
|
+
const pageIds = remaining.slice(0, limit);
|
|
198
|
+
const records = await Promise.all(pageIds.map(id => this.readPending(teamId, recipientId, id)));
|
|
199
|
+
const messages = records.filter((message): message is Envelope => message !== undefined);
|
|
200
|
+
const nextCursor = remaining.length > limit ? pageIds.at(-1) : undefined;
|
|
201
|
+
return { messages, ...(nextCursor ? { nextCursor } : {}) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async listClaimed(teamId: string, recipientId: string, limit = 50, cursor?: string): Promise<InboxPage> {
|
|
205
|
+
assertTeamId(teamId);
|
|
206
|
+
assertEntityId(recipientId, 'recipient ID');
|
|
207
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('Inbox page limit must be between 1 and 100.');
|
|
208
|
+
if (cursor !== undefined) assertEntityId(cursor, 'inbox cursor');
|
|
209
|
+
await this.prepare();
|
|
210
|
+
await this.requireTeam(teamId);
|
|
211
|
+
if (!await this.prepareRecipient(teamId, recipientId, false)) return { messages: [] };
|
|
212
|
+
const ids = await jsonFileNames(this.paths.claimed(teamId, recipientId), false);
|
|
213
|
+
const remaining = cursor === undefined ? ids : ids.filter(id => id > cursor);
|
|
214
|
+
const pageIds = remaining.slice(0, limit);
|
|
215
|
+
const records = await Promise.all(pageIds.map(id => this.readClaimed(teamId, recipientId, id)));
|
|
216
|
+
const messages = records.filter((message): message is Envelope => message !== undefined);
|
|
217
|
+
const nextCursor = remaining.length > limit ? pageIds.at(-1) : undefined;
|
|
218
|
+
return { messages, ...(nextCursor ? { nextCursor } : {}) };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async claim(teamId: string, recipientId: string, messageId: string): Promise<Envelope> {
|
|
222
|
+
await this.prepare();
|
|
223
|
+
return withStorageLock(this.paths.inboxLock(teamId), async () => {
|
|
224
|
+
await this.requireTeam(teamId, true);
|
|
225
|
+
const message = await this.readPending(teamId, recipientId, messageId);
|
|
226
|
+
if (!message) throw Object.assign(new Error(`Unknown pending message "${messageId}".`), { code: 'NOT_FOUND' });
|
|
227
|
+
if (messageExpired(message, this.now())) throw Object.assign(new Error('Message has expired.'), { code: 'EXPIRED' });
|
|
228
|
+
await this.prepareRecipient(teamId, recipientId, true);
|
|
229
|
+
await moveAtomic(
|
|
230
|
+
this.paths.pendingMessage(teamId, recipientId, messageId),
|
|
231
|
+
this.paths.claimedMessage(teamId, recipientId, messageId),
|
|
232
|
+
);
|
|
233
|
+
return message;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async release(teamId: string, recipientId: string, messageId: string): Promise<void> {
|
|
238
|
+
await this.prepare();
|
|
239
|
+
await withStorageLock(this.paths.inboxLock(teamId), async () => {
|
|
240
|
+
await this.requireTeam(teamId);
|
|
241
|
+
const message = await readJson(
|
|
242
|
+
this.paths.claimedMessage(teamId, recipientId, messageId), assertEnvelope, MESSAGE_MAX_BYTES,
|
|
243
|
+
);
|
|
244
|
+
if (!message) throw Object.assign(new Error(`Unknown claimed message "${messageId}".`), { code: 'NOT_FOUND' });
|
|
245
|
+
this.assertStoredMessage(message, teamId, recipientId, messageId);
|
|
246
|
+
await this.prepareRecipient(teamId, recipientId, true);
|
|
247
|
+
await moveAtomic(
|
|
248
|
+
this.paths.claimedMessage(teamId, recipientId, messageId),
|
|
249
|
+
this.paths.pendingMessage(teamId, recipientId, messageId),
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async removePending(teamId: string, recipientId: string, messageId: string): Promise<boolean> {
|
|
255
|
+
await this.prepare();
|
|
256
|
+
return withStorageLock(this.paths.inboxLock(teamId), async () => {
|
|
257
|
+
await this.requireTeam(teamId);
|
|
258
|
+
if (!await this.prepareRecipient(teamId, recipientId, false)) return false;
|
|
259
|
+
return removeAtomic(this.paths.pendingMessage(teamId, recipientId, messageId));
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async removeClaimed(teamId: string, recipientId: string, messageId: string): Promise<boolean> {
|
|
264
|
+
await this.prepare();
|
|
265
|
+
return withStorageLock(this.paths.inboxLock(teamId), async () => {
|
|
266
|
+
await this.requireTeam(teamId);
|
|
267
|
+
if (!await this.prepareRecipient(teamId, recipientId, false)) return false;
|
|
268
|
+
return removeAtomic(this.paths.claimedMessage(teamId, recipientId, messageId));
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async purgeExpired(teamId: string, recipientId: string): Promise<number> {
|
|
273
|
+
await this.prepare();
|
|
274
|
+
return withStorageLock(this.paths.inboxLock(teamId), async () => {
|
|
275
|
+
await this.requireTeam(teamId);
|
|
276
|
+
if (!await this.prepareRecipient(teamId, recipientId, false)) return 0;
|
|
277
|
+
const directories = [this.paths.pending(teamId, recipientId), this.paths.claimed(teamId, recipientId)];
|
|
278
|
+
const idsByDirectory = await Promise.all(directories.map(path => jsonFileNames(path, false)));
|
|
279
|
+
const candidates = directories.flatMap((path, index) => idsByDirectory[index].map(id => ({ path, id })));
|
|
280
|
+
const expired = (await Promise.all(candidates.map(async candidate => {
|
|
281
|
+
const message = await readJson(`${candidate.path}/${candidate.id}.json`, assertEnvelope, MESSAGE_MAX_BYTES);
|
|
282
|
+
if (!message) return undefined;
|
|
283
|
+
this.assertStoredMessage(message, teamId, recipientId, candidate.id);
|
|
284
|
+
return messageExpired(message, this.now()) ? candidate : undefined;
|
|
285
|
+
}))).filter((candidate): candidate is { path: string; id: string } => candidate !== undefined);
|
|
286
|
+
await Promise.all(expired.map(candidate => removeAtomic(`${candidate.path}/${candidate.id}.json`)));
|
|
287
|
+
return expired.length;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|