@twinsearth/w2m-dsh-plugin 1.3.1 → 1.3.11
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +588 -0
- package/bin/w2m-maintenance.mjs +112 -0
- package/codex/mcp-servers.toml +1 -1
- package/deploy/maintenance/bootstrap.ps1 +30 -0
- package/deploy/maintenance/bootstrap.sh +59 -0
- package/deploy/server/w2m-maintenance.path +10 -0
- package/deploy/server/w2m-maintenance.service +20 -0
- package/deploy/windows/w2m-maintenance.xml +31 -0
- package/docs/COLLAB/COLLABORATION-MODEL.md +240 -0
- package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +250 -0
- package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +70 -0
- package/docs/COLLAB/M1-MAINTENANCE.md +313 -0
- package/docs/COLLAB/M2-TASK-GRANT.md +231 -0
- package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +291 -0
- package/docs/COLLAB/M4-PROJECT-ROOM.md +260 -0
- package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +263 -0
- package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +231 -0
- package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +263 -0
- package/docs/COLLAB/TASK-MODEL.md +281 -0
- package/docs/MULTI-MACHINE-COLLABORATION-PLAN.md +186 -0
- package/docs/RELEASE-STABLE.md +193 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-DIAG.json +27 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-POSTUPDATE-DIAG.json +18 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-RESTART.json +24 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-UPDATE.json +24 -0
- package/docs/acceptance/1.3.11/M1-LIVE-DIAG.json +26 -0
- package/package.json +22 -4
- package/plugin.json +1 -1
- package/src/acceptance/evidence.mjs +79 -0
- package/src/agent/agent.mjs +64 -2
- package/src/agent/grant-gate.mjs +82 -0
- package/src/agent/maintenance-ferry.mjs +111 -0
- package/src/agent/outbox.mjs +138 -0
- package/src/agent/patch-artifact.mjs +53 -0
- package/src/agent/worktree.mjs +109 -0
- package/src/maintenance/actions.mjs +203 -0
- package/src/maintenance/grant.mjs +139 -0
- package/src/maintenance/operator-key.mjs +151 -0
- package/src/maintenance/protocol.mjs +204 -0
- package/src/maintenance/runner.mjs +189 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/blob-sessions.mjs +148 -0
- package/src/relay/merge-queue.mjs +137 -0
- package/src/relay/project-locks.mjs +179 -0
- package/src/relay/project-notifications.mjs +182 -0
- package/src/relay/project-reviews.mjs +161 -0
- package/src/relay/project-room.mjs +163 -0
- package/src/relay/reliability-metrics.mjs +180 -0
- package/src/relay/server.mjs +164 -1
- package/src/relay/state.mjs +128 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chunked blob sessions (M6, v1.3.10-beta.19 slice).
|
|
3
|
+
*
|
|
4
|
+
* The current blob endpoint is one PUT with a frame cap: an interrupted upload loses everything, and a large patch cannot be
|
|
5
|
+
* sent at all. A blob session fixes both: the client declares the final sha256 and size once, uploads chunks in any order (each
|
|
6
|
+
* PUT is idempotent), asks what is missing, and commits. Only a commit whose assembled bytes hash to the declared sha256 creates
|
|
7
|
+
* a blob; anything else leaves no artifact behind.
|
|
8
|
+
*
|
|
9
|
+
* The store is injected as a set of in-memory sessions here; the relay binds it to its blob store in the wiring slice.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
export const CHUNK_LIMITS = Object.freeze({
|
|
14
|
+
defaultChunkBytes: 1024 * 1024,
|
|
15
|
+
maxChunkBytes: 8 * 1024 * 1024,
|
|
16
|
+
maxChunks: 256,
|
|
17
|
+
maxBlobBytes: 64 * 1024 * 1024,
|
|
18
|
+
maxSessionsPerMachine: 2,
|
|
19
|
+
maxUnfinishedBytesPerMachine: 512 * 1024 * 1024,
|
|
20
|
+
ttlMs: 24 * 60 * 60 * 1000,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export class BlobSessionError extends Error {
|
|
24
|
+
constructor(code, message, detail = null) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'BlobSessionError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.detail = detail;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sha256Hex = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
|
33
|
+
|
|
34
|
+
export function createBlobSessions({ now = () => Date.now(), limits = CHUNK_LIMITS, onCommit = null } = {}) {
|
|
35
|
+
const sessions = new Map();
|
|
36
|
+
let counter = 0;
|
|
37
|
+
|
|
38
|
+
const purgeExpired = () => {
|
|
39
|
+
const at = now();
|
|
40
|
+
for (const session of [...sessions.values()]) if (session.expires_at_ms <= at) sessions.delete(session.session_id);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const requireSession = (sessionId) => {
|
|
44
|
+
purgeExpired();
|
|
45
|
+
const session = sessions.get(sessionId);
|
|
46
|
+
if (session === undefined) throw new BlobSessionError('SESSION_NOT_FOUND', 'session missing or expired', { session_id: sessionId });
|
|
47
|
+
return session;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const describe = (session) => {
|
|
51
|
+
const received = [...session.chunks.keys()].sort((a, b) => a - b);
|
|
52
|
+
const missing = [];
|
|
53
|
+
for (let index = 0; index < session.chunks_total; index += 1) if (!session.chunks.has(index)) missing.push(index);
|
|
54
|
+
return {
|
|
55
|
+
session_id: session.session_id,
|
|
56
|
+
sha256: session.sha256,
|
|
57
|
+
size: session.size,
|
|
58
|
+
chunk_bytes: session.chunk_bytes,
|
|
59
|
+
chunks: session.chunks_total,
|
|
60
|
+
received,
|
|
61
|
+
missing,
|
|
62
|
+
expires_at: new Date(session.expires_at_ms).toISOString(),
|
|
63
|
+
completed: session.state === 'committed',
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
create({ machineId, sha256, size, chunkBytes = limits.defaultChunkBytes }) {
|
|
69
|
+
if (typeof machineId !== 'string' || machineId === '') throw new BlobSessionError('INVALID_MACHINE', 'machine_id is required');
|
|
70
|
+
if (typeof sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(sha256)) throw new BlobSessionError('INVALID_SHA', 'sha256 must be 64 lowercase hex characters');
|
|
71
|
+
if (!Number.isInteger(size) || size <= 0) throw new BlobSessionError('INVALID_SIZE', 'size must be a positive integer');
|
|
72
|
+
if (size > limits.maxBlobBytes) throw new BlobSessionError('BLOB_TOO_LARGE', 'blob exceeds the configured maximum', { size, max_bytes: limits.maxBlobBytes });
|
|
73
|
+
if (!Number.isInteger(chunkBytes) || chunkBytes <= 0 || chunkBytes > limits.maxChunkBytes) throw new BlobSessionError('INVALID_CHUNK_BYTES', 'chunk_bytes out of range', { chunk_bytes: chunkBytes, max: limits.maxChunkBytes });
|
|
74
|
+
const chunksTotal = Math.ceil(size / chunkBytes);
|
|
75
|
+
if (chunksTotal > limits.maxChunks) throw new BlobSessionError('CHUNK_TOO_LARGE', 'too many chunks for one blob', { chunks: chunksTotal, max: limits.maxChunks });
|
|
76
|
+
purgeExpired();
|
|
77
|
+
const mine = [...sessions.values()].filter((session) => session.machine_id === machineId);
|
|
78
|
+
if (mine.length >= limits.maxSessionsPerMachine) {
|
|
79
|
+
throw new BlobSessionError('BLOB_QUOTA', 'too many open sessions for this machine', { open: mine.length, max: limits.maxSessionsPerMachine });
|
|
80
|
+
}
|
|
81
|
+
const unfinishedBytes = mine.reduce((sum, session) => sum + session.size, 0);
|
|
82
|
+
if (unfinishedBytes + size > limits.maxUnfinishedBytesPerMachine) {
|
|
83
|
+
throw new BlobSessionError('BLOB_QUOTA', 'unfinished session bytes exceed the machine allowance', { unfinished_bytes: unfinishedBytes, size, max_bytes: limits.maxUnfinishedBytesPerMachine });
|
|
84
|
+
}
|
|
85
|
+
counter += 1;
|
|
86
|
+
const session = {
|
|
87
|
+
session_id: 'bs-' + String(counter).padStart(6, '0'),
|
|
88
|
+
machine_id: machineId,
|
|
89
|
+
sha256,
|
|
90
|
+
size,
|
|
91
|
+
chunk_bytes: chunkBytes,
|
|
92
|
+
chunks_total: chunksTotal,
|
|
93
|
+
chunks: new Map(),
|
|
94
|
+
state: 'open',
|
|
95
|
+
created_at: new Date(now()).toISOString(),
|
|
96
|
+
expires_at_ms: now() + limits.ttlMs,
|
|
97
|
+
};
|
|
98
|
+
sessions.set(session.session_id, session);
|
|
99
|
+
return { ok: true, session: describe(session) };
|
|
100
|
+
},
|
|
101
|
+
/** Upload one chunk; re-uploading an index is an overwrite, and order does not matter. */
|
|
102
|
+
putChunk({ sessionId, index, data }) {
|
|
103
|
+
const session = requireSession(sessionId);
|
|
104
|
+
if (session.state === 'committed') throw new BlobSessionError('SESSION_COMMITTED', 'session already committed', { session_id: sessionId });
|
|
105
|
+
if (!Number.isInteger(index) || index < 0 || index >= session.chunks_total) {
|
|
106
|
+
throw new BlobSessionError('INVALID_CHUNK_INDEX', 'chunk index out of range', { index, chunks: session.chunks_total });
|
|
107
|
+
}
|
|
108
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data ?? []);
|
|
109
|
+
if (buffer.length === 0) throw new BlobSessionError('INVALID_CHUNK', 'chunk is empty', { index });
|
|
110
|
+
if (buffer.length > session.chunk_bytes) throw new BlobSessionError('CHUNK_TOO_LARGE', 'chunk exceeds chunk_bytes', { index, bytes: buffer.length, chunk_bytes: session.chunk_bytes });
|
|
111
|
+
const isLast = index === session.chunks_total - 1;
|
|
112
|
+
const expected = isLast ? session.size - session.chunk_bytes * (session.chunks_total - 1) : session.chunk_bytes;
|
|
113
|
+
if (buffer.length !== expected) throw new BlobSessionError('INVALID_CHUNK', 'chunk length does not match the declared size', { index, bytes: buffer.length, expected });
|
|
114
|
+
session.chunks.set(index, buffer);
|
|
115
|
+
return { ok: true, session: describe(session) };
|
|
116
|
+
},
|
|
117
|
+
status({ sessionId }) {
|
|
118
|
+
return { ok: true, session: describe(requireSession(sessionId)) };
|
|
119
|
+
},
|
|
120
|
+
/** Commit only if every chunk arrived and the assembled sha256 equals the declared one. */
|
|
121
|
+
commit({ sessionId }) {
|
|
122
|
+
const session = requireSession(sessionId);
|
|
123
|
+
if (session.state === 'committed') throw new BlobSessionError('SESSION_COMMITTED', 'session already committed', { session_id: sessionId });
|
|
124
|
+
const detail = describe(session);
|
|
125
|
+
if (detail.missing.length > 0) {
|
|
126
|
+
return { ok: false, code: 'BLOB_INCOMPLETE', missing: detail.missing, session_id: sessionId };
|
|
127
|
+
}
|
|
128
|
+
const ordered = [];
|
|
129
|
+
for (let index = 0; index < session.chunks_total; index += 1) ordered.push(session.chunks.get(index));
|
|
130
|
+
const assembled = Buffer.concat(ordered);
|
|
131
|
+
if (assembled.length !== session.size) return { ok: false, code: 'BLOB_SIZE_MISMATCH', size: assembled.length, expected: session.size };
|
|
132
|
+
const actual = sha256Hex(assembled);
|
|
133
|
+
if (actual !== session.sha256) {
|
|
134
|
+
sessions.delete(sessionId);
|
|
135
|
+
return { ok: false, code: 'BLOB_SHA_MISMATCH', expected: session.sha256, actual };
|
|
136
|
+
}
|
|
137
|
+
session.state = 'committed';
|
|
138
|
+
session.chunks.clear();
|
|
139
|
+
sessions.delete(sessionId);
|
|
140
|
+
if (typeof onCommit === 'function') onCommit({ machineId: session.machine_id, sha256: session.sha256, size: session.size, bytes: assembled });
|
|
141
|
+
return { ok: true, sha256: session.sha256, bytes: assembled.length };
|
|
142
|
+
},
|
|
143
|
+
size() {
|
|
144
|
+
purgeExpired();
|
|
145
|
+
return { open_sessions: sessions.size };
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge queue (M3, v1.3.10-beta.7 slice).
|
|
3
|
+
*
|
|
4
|
+
* Every project+branch has one queue and at most one holder of its merge slot. The queue accepts a patch relative to a
|
|
5
|
+
* declared base and either advances the branch head, reports that the patch is already merged, refuses a stale base, or
|
|
6
|
+
* reports a structured conflict. It never force-pushes and never advances the head on a conflict.
|
|
7
|
+
*
|
|
8
|
+
* Policy is a project setting:
|
|
9
|
+
* strict base_commit must equal the current head; anything else is BASE_MISMATCH and the submitter rebases.
|
|
10
|
+
* replay the patch is applied with --3way onto the current head; if it applies, it merges, otherwise MERGE_CONFLICT.
|
|
11
|
+
*
|
|
12
|
+
* Git access is injected so the tests can drive real repositories without a network or a daemon.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
16
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
|
|
20
|
+
export const MERGE_POLICIES = Object.freeze(['strict', 'replay']);
|
|
21
|
+
|
|
22
|
+
export function defaultGit() {
|
|
23
|
+
return (args, cwd) => new Promise((resolve, reject) => {
|
|
24
|
+
execFile('git', args, { cwd, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
25
|
+
if (error) reject(Object.assign(new Error(String(stderr || error.message).trim()), { stdout, stderr }));
|
|
26
|
+
else resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function patchSha(patch) {
|
|
32
|
+
return createHash('sha256').update(String(patch), 'utf8').digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function changedFiles(patch) {
|
|
36
|
+
const files = new Set();
|
|
37
|
+
for (const line of String(patch).split('\n')) {
|
|
38
|
+
const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line.trim());
|
|
39
|
+
if (match) files.add(match[2]);
|
|
40
|
+
}
|
|
41
|
+
return [...files].sort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createMergeQueue({ policy = 'strict', slotTtlMs = 10 * 60 * 1000, now = () => Date.now() } = {}) {
|
|
45
|
+
if (!MERGE_POLICIES.includes(policy)) throw new Error('unknown merge policy: ' + policy);
|
|
46
|
+
const items = new Map();
|
|
47
|
+
const merged = new Map(); // patchSha + '|' + branch -> { headCommit, queue_id }
|
|
48
|
+
let slot = null;
|
|
49
|
+
let seq = 0;
|
|
50
|
+
|
|
51
|
+
const queueKey = (sha, branch) => sha + '|' + branch;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
policy,
|
|
55
|
+
enqueue({ patch, baseCommit, branch = 'main', sourceTaskId = null }) {
|
|
56
|
+
const sha = patchSha(patch);
|
|
57
|
+
if (merged.has(queueKey(sha, branch))) {
|
|
58
|
+
return { ok: true, alreadyMerged: true, queueId: merged.get(queueKey(sha, branch)).queue_id, headCommit: merged.get(queueKey(sha, branch)).headCommit };
|
|
59
|
+
}
|
|
60
|
+
seq += 1;
|
|
61
|
+
const item = { queueId: 'mq-' + String(seq).padStart(6, '0'), patchSha: sha, patch, baseCommit, branch, sourceTaskId, state: 'queued', createdAt: now() };
|
|
62
|
+
items.set(item.queueId, item);
|
|
63
|
+
return { ok: true, alreadyMerged: false, item };
|
|
64
|
+
},
|
|
65
|
+
claim(holder) {
|
|
66
|
+
const current = now();
|
|
67
|
+
if (slot !== null && slot.expiresAt > current) return { ok: false, code: 'MERGE_SLOT_BUSY', holder: slot.holder, expiresAt: slot.expiresAt };
|
|
68
|
+
slot = { holder, expiresAt: current + slotTtlMs, claimedAt: current };
|
|
69
|
+
return { ok: true, holder };
|
|
70
|
+
},
|
|
71
|
+
release(holder) {
|
|
72
|
+
if (slot === null || slot.holder !== holder) return { ok: false, code: 'MERGE_SLOT_NOT_OWNER' };
|
|
73
|
+
slot = null;
|
|
74
|
+
return { ok: true };
|
|
75
|
+
},
|
|
76
|
+
next() {
|
|
77
|
+
for (const item of items.values()) if (item.state === 'queued') return item;
|
|
78
|
+
return null;
|
|
79
|
+
},
|
|
80
|
+
finish(queueId, state) {
|
|
81
|
+
const item = items.get(queueId);
|
|
82
|
+
if (!item) return { ok: false, code: 'ITEM_NOT_FOUND' };
|
|
83
|
+
item.state = state;
|
|
84
|
+
item.finishedAt = now();
|
|
85
|
+
return { ok: true, item };
|
|
86
|
+
},
|
|
87
|
+
status() {
|
|
88
|
+
return { policy, slot, items: [...items.values()], mergedCount: merged.size };
|
|
89
|
+
},
|
|
90
|
+
/**
|
|
91
|
+
* Apply one item to a repository checked out at headCommit. Returns a terminal verdict, never an exception.
|
|
92
|
+
*/
|
|
93
|
+
async apply({ item, queueId, repoDir, headCommit, git = defaultGit() }) {
|
|
94
|
+
const target = item ?? items.get(queueId);
|
|
95
|
+
if (!target) return { ok: false, code: 'ITEM_NOT_FOUND' };
|
|
96
|
+
if (target.state !== 'queued') return { ok: false, code: 'ITEM_NOT_QUEUED', state: target.state };
|
|
97
|
+
if (policy === 'strict' && target.baseCommit !== headCommit) {
|
|
98
|
+
this.finish(target.queueId, 'failed');
|
|
99
|
+
return { ok: false, code: 'BASE_MISMATCH', baseCommit: target.baseCommit, headCommit };
|
|
100
|
+
}
|
|
101
|
+
const worktree = mkdtempSync(join(tmpdir(), 'w2m-merge-'));
|
|
102
|
+
try {
|
|
103
|
+
await git(['worktree', 'add', '--detach', worktree, headCommit], repoDir);
|
|
104
|
+
try {
|
|
105
|
+
const patchFile = join(worktree, '.w2m-merge.patch');
|
|
106
|
+
writeFileSync(patchFile, target.patch, 'utf8');
|
|
107
|
+
await git(['apply', '--3way', '--whitespace=nowarn', patchFile], worktree);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
this.finish(target.queueId, 'conflict');
|
|
110
|
+
// The conflict itself is evidence, not just a failure: capture the file state git left behind (conflict markers
|
|
111
|
+
// included) so a reviewer can see exactly what clashed, and the head can stay where it was.
|
|
112
|
+
let conflictArtifact = null;
|
|
113
|
+
try {
|
|
114
|
+
const diff = await git(['diff'], worktree);
|
|
115
|
+
const text = diff.stdout;
|
|
116
|
+
if (typeof text === 'string' && text.trim() !== '') {
|
|
117
|
+
conflictArtifact = { sha256: patchSha(text), bytes: Buffer.byteLength(text, 'utf8'), text: text.slice(0, 64 * 1024) };
|
|
118
|
+
}
|
|
119
|
+
} catch { /* the diff is evidence, not a precondition for reporting the conflict */ }
|
|
120
|
+
return { ok: false, code: 'MERGE_CONFLICT', files: changedFiles(target.patch), detail: String(error?.message ?? error).split('\n').slice(0, 6), conflictArtifact, baseCommit: target.baseCommit, headCommit };
|
|
121
|
+
}
|
|
122
|
+
await git(['add', '--all'], worktree);
|
|
123
|
+
await git(['-c', 'user.name=w2m-merge', '-c', 'user.email=merge@w2m.invalid', 'commit', '-m', 'merge: ' + target.queueId], worktree);
|
|
124
|
+
const newHead = (await git(['rev-parse', 'HEAD'], worktree)).stdout.trim();
|
|
125
|
+
// The commit was made in a detached worktree; the branch must actually advance, and the repository working tree
|
|
126
|
+
// must follow it, or the next merge would start from a head that no longer matches what was reviewed.
|
|
127
|
+
await git(['reset', '--hard', newHead], repoDir);
|
|
128
|
+
this.finish(target.queueId, 'merged');
|
|
129
|
+
merged.set(queueKey(target.patchSha, target.branch), { headCommit: newHead, queue_id: target.queueId });
|
|
130
|
+
return { ok: true, code: 'MERGED', headCommit: newHead, queueId: target.queueId, files: changedFiles(target.patch) };
|
|
131
|
+
} finally {
|
|
132
|
+
try { await git(['worktree', 'remove', '--force', worktree], repoDir); } catch { /* removal is best effort */ }
|
|
133
|
+
try { rmSync(worktree, { recursive: true, force: true }); } catch { /* see above */ }
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project locks (M5, v1.3.10-beta.15 slice).
|
|
3
|
+
*
|
|
4
|
+
* Two coordination primitives, one module: an exclusive branch lock and an exclusive file lock. They exist so two clients
|
|
5
|
+
* disagree about *who is editing what* before they disagree about merge conflicts, and so a crashed client's claim expires
|
|
6
|
+
* instead of blocking the project forever.
|
|
7
|
+
*
|
|
8
|
+
* Rules (docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md §2):
|
|
9
|
+
* - one exclusive holder per (project, branch) or (project, path);
|
|
10
|
+
* - a lock has a lease and is renewed by its holder; an expired lock does not block a newcomer (it is released as it is taken,
|
|
11
|
+
* and both the expiry and the new acquisition are events);
|
|
12
|
+
* - nobody steals a live lock; the owner may force-release with a reason, which is also an event;
|
|
13
|
+
* - every mutation is recorded as an event for the project room to append.
|
|
14
|
+
*
|
|
15
|
+
* This module holds no relay state of its own; state is injected so it can be tested and replayed.
|
|
16
|
+
*/
|
|
17
|
+
export const LOCK_KINDS = Object.freeze(['branch', 'file']);
|
|
18
|
+
export const DEFAULT_LOCK_LEASE_MS = 30 * 60 * 1000;
|
|
19
|
+
export const DEFAULT_LOCK_LIMITS = Object.freeze({ perProject: 20, perHolder: 10 });
|
|
20
|
+
|
|
21
|
+
export class LockError extends Error {
|
|
22
|
+
constructor(code, message, detail = null) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'LockError';
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.detail = detail;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const holderKey = (holder) => holder.kind + ':' + holder.id;
|
|
31
|
+
|
|
32
|
+
export function createProjectLocks({ now = () => Date.now(), defaultLeaseMs = DEFAULT_LOCK_LEASE_MS, limits = DEFAULT_LOCK_LIMITS, onEvent = null } = {}) {
|
|
33
|
+
const locks = new Map(); // lock_id -> lock
|
|
34
|
+
let counter = 0;
|
|
35
|
+
let seq = 0;
|
|
36
|
+
|
|
37
|
+
const emit = (type, payload) => {
|
|
38
|
+
seq += 1;
|
|
39
|
+
const event = { seq, type, at: new Date(now()).toISOString(), payload };
|
|
40
|
+
if (typeof onEvent === 'function') onEvent(event);
|
|
41
|
+
return event;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const targetKey = (projectId, kind, target) => projectId + '|' + kind + '|' + (kind === 'branch' ? target.branch : target.path);
|
|
45
|
+
|
|
46
|
+
/** Expire every lock whose lease ran out, recording one event each. Called lazily by every operation. */
|
|
47
|
+
const expireDue = () => {
|
|
48
|
+
const expired = [];
|
|
49
|
+
for (const lock of locks.values()) {
|
|
50
|
+
if (lock.state === 'held' && lock.expires_at_ms <= now()) {
|
|
51
|
+
lock.state = 'expired';
|
|
52
|
+
expired.push(lock);
|
|
53
|
+
emit('lock.expired', { lock_id: lock.lock_id, project_id: lock.project_id, kind: lock.kind, target: lock.target, holder: lock.holder });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return expired;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const describe = (lock) => ({
|
|
60
|
+
lock_id: lock.lock_id,
|
|
61
|
+
project_id: lock.project_id,
|
|
62
|
+
kind: lock.kind,
|
|
63
|
+
target: lock.target,
|
|
64
|
+
mode: 'exclusive',
|
|
65
|
+
holder: lock.holder,
|
|
66
|
+
reason: lock.reason,
|
|
67
|
+
acquired_at: lock.acquired_at,
|
|
68
|
+
expires_at: lock.expires_at,
|
|
69
|
+
lease_ms: lock.lease_ms,
|
|
70
|
+
renewed_at: lock.renewed_at,
|
|
71
|
+
state: lock.state,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
/** Acquire an exclusive lock. An expired lock on the same target does not block the newcomer. */
|
|
76
|
+
acquire({ projectId, kind, target, holder, reason = null, leaseMs = defaultLeaseMs }) {
|
|
77
|
+
if (typeof projectId !== 'string' || projectId === '') throw new LockError('INVALID_PROJECT', 'project_id is required');
|
|
78
|
+
if (!LOCK_KINDS.includes(kind)) throw new LockError('INVALID_LOCK_KIND', 'kind must be branch or file', { kind });
|
|
79
|
+
if (!target || typeof target !== 'object') throw new LockError('INVALID_TARGET', 'target is required', { kind });
|
|
80
|
+
if (kind === 'branch' && (typeof target.branch !== 'string' || target.branch === '')) throw new LockError('INVALID_TARGET', 'a branch lock needs target.branch', { target });
|
|
81
|
+
if (kind === 'file' && (typeof target.path !== 'string' || target.path === '')) throw new LockError('INVALID_TARGET', 'a file lock needs target.path', { target });
|
|
82
|
+
if (!holder || typeof holder.kind !== 'string' || typeof holder.id !== 'string' || holder.id === '') throw new LockError('INVALID_HOLDER', 'holder needs kind and id');
|
|
83
|
+
if (!Number.isFinite(leaseMs) || leaseMs <= 0) throw new LockError('INVALID_LEASE', 'lease_ms must be positive', { lease_ms: leaseMs });
|
|
84
|
+
expireDue();
|
|
85
|
+
const key = targetKey(projectId, kind, target);
|
|
86
|
+
const existing = [...locks.values()].find((lock) => lock.state === 'held' && targetKey(lock.projectId, lock.kind, lock.target) === key);
|
|
87
|
+
if (existing !== undefined) {
|
|
88
|
+
return { ok: false, code: 'LOCK_HELD', holder: existing.holder, lock_id: existing.lock_id, expires_at: existing.expires_at };
|
|
89
|
+
}
|
|
90
|
+
const current = [...locks.values()].filter((lock) => lock.state === 'held' && lock.projectId === projectId);
|
|
91
|
+
if (current.length >= limits.perProject) return { ok: false, code: 'LOCK_LIMIT', limit: limits.perProject, scope: 'project' };
|
|
92
|
+
const mine = current.filter((lock) => holderKey(lock.holder) === holderKey(holder));
|
|
93
|
+
if (mine.length >= limits.perHolder) return { ok: false, code: 'LOCK_LIMIT', limit: limits.perHolder, scope: 'holder' };
|
|
94
|
+
counter += 1;
|
|
95
|
+
const acquired = now();
|
|
96
|
+
const lock = {
|
|
97
|
+
lock_id: 'lk-' + String(counter).padStart(6, '0'),
|
|
98
|
+
projectId,
|
|
99
|
+
kind,
|
|
100
|
+
target: kind === 'branch' ? { branch: target.branch } : { path: target.path },
|
|
101
|
+
holder: { kind: holder.kind, id: holder.id },
|
|
102
|
+
reason,
|
|
103
|
+
lease_ms: leaseMs,
|
|
104
|
+
acquired_at: new Date(acquired).toISOString(),
|
|
105
|
+
expires_at: new Date(acquired + leaseMs).toISOString(),
|
|
106
|
+
expires_at_ms: acquired + leaseMs,
|
|
107
|
+
renewed_at: null,
|
|
108
|
+
state: 'held',
|
|
109
|
+
};
|
|
110
|
+
locks.set(lock.lock_id, lock);
|
|
111
|
+
emit('lock.acquired', { lock_id: lock.lock_id, project_id: projectId, kind, target: lock.target, holder: lock.holder, reason, lease_ms: leaseMs });
|
|
112
|
+
return { ok: true, lock: describe(lock) };
|
|
113
|
+
},
|
|
114
|
+
/** Renew a lock you hold. Never extends a lock someone else holds, and an expired lock cannot be revived. */
|
|
115
|
+
renew({ projectId, lockId, holder, leaseMs = defaultLeaseMs }) {
|
|
116
|
+
expireDue();
|
|
117
|
+
const lock = locks.get(lockId);
|
|
118
|
+
if (lock === undefined) return { ok: false, code: 'LOCK_NOT_FOUND', lock_id: lockId };
|
|
119
|
+
if (lock.project_id !== undefined) { /* set below */ }
|
|
120
|
+
if (lock.projectId !== projectId) return { ok: false, code: 'LOCK_NOT_FOUND', lock_id: lockId };
|
|
121
|
+
if (lock.state === 'expired') return { ok: false, code: 'LOCK_EXPIRED', lock_id: lockId };
|
|
122
|
+
if (holderKey(lock.holder) !== holderKey(holder)) return { ok: false, code: 'LOCK_NOT_OWNER', holder: lock.holder };
|
|
123
|
+
const renewedAt = now();
|
|
124
|
+
lock.lease_ms = leaseMs;
|
|
125
|
+
lock.expires_at_ms = renewedAt + leaseMs;
|
|
126
|
+
lock.expires_at = new Date(lock.expires_at_ms).toISOString();
|
|
127
|
+
lock.renewed_at = new Date(renewedAt).toISOString();
|
|
128
|
+
emit('lock.renewed', { lock_id: lock.lock_id, project_id: projectId, holder: lock.holder, expires_at: lock.expires_at });
|
|
129
|
+
return { ok: true, lock: describe(lock) };
|
|
130
|
+
},
|
|
131
|
+
/** Release your own lock. */
|
|
132
|
+
release({ projectId, lockId, holder }) {
|
|
133
|
+
expireDue();
|
|
134
|
+
const lock = locks.get(lockId);
|
|
135
|
+
if (lock === undefined || lock.projectId !== projectId) return { ok: false, code: 'LOCK_NOT_FOUND', lock_id: lockId };
|
|
136
|
+
if (lock.state === 'expired') return { ok: false, code: 'LOCK_EXPIRED', lock_id: lockId };
|
|
137
|
+
if (holderKey(lock.holder) !== holderKey(holder)) return { ok: false, code: 'LOCK_NOT_OWNER', holder: lock.holder };
|
|
138
|
+
lock.state = 'released';
|
|
139
|
+
emit('lock.released', { lock_id: lock.lock_id, project_id: projectId, holder: lock.holder });
|
|
140
|
+
return { ok: true };
|
|
141
|
+
},
|
|
142
|
+
/** Owner-only force release; a reason is mandatory and is recorded with the decision. */
|
|
143
|
+
forceRelease({ projectId, lockId, actor, actorRole, reason }) {
|
|
144
|
+
expireDue();
|
|
145
|
+
const lock = locks.get(lockId);
|
|
146
|
+
if (lock === undefined || lock.projectId !== projectId) return { ok: false, code: 'LOCK_NOT_FOUND', lock_id: lockId };
|
|
147
|
+
if (actorRole !== 'owner') return { ok: false, code: 'LOCK_FORCE_DENIED', required_role: 'owner', actor_role: actorRole };
|
|
148
|
+
if (typeof reason !== 'string' || reason.trim() === '') return { ok: false, code: 'LOCK_FORCE_DENIED', required: 'reason' };
|
|
149
|
+
const wasHolder = { ...lock.holder };
|
|
150
|
+
lock.state = 'force_released';
|
|
151
|
+
emit('lock.force_released', { lock_id: lock.lock_id, project_id: projectId, holder: wasHolder, actor, reason: reason.trim() });
|
|
152
|
+
return { ok: true, previous_holder: wasHolder };
|
|
153
|
+
},
|
|
154
|
+
/** Locks a reader should show for a project (live ones only; expired ones are history). */
|
|
155
|
+
list({ projectId }) {
|
|
156
|
+
expireDue();
|
|
157
|
+
return [...locks.values()]
|
|
158
|
+
.filter((lock) => lock.projectId === projectId && lock.state === 'held')
|
|
159
|
+
.map((lock) => describe(lock));
|
|
160
|
+
},
|
|
161
|
+
/** Would a write to this branch / these paths collide with someone else's claim? Used by the merge preflight (§2.3). */
|
|
162
|
+
conflicts({ projectId, branch = null, paths = [] }) {
|
|
163
|
+
expireDue();
|
|
164
|
+
const result = [];
|
|
165
|
+
for (const lock of locks.values()) {
|
|
166
|
+
if (lock.projectId !== projectId || lock.state !== 'held') continue;
|
|
167
|
+
const branchHit = branch !== null && lock.kind === 'branch' && lock.target.branch === branch;
|
|
168
|
+
const pathHit = lock.kind === 'file' && paths.includes(lock.target.path);
|
|
169
|
+
if (branchHit || pathHit) result.push({ lock_id: lock.lock_id, kind: lock.kind, target: lock.target, holder: lock.holder, expires_at: lock.expires_at });
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
},
|
|
173
|
+
expiresAt({ lockId }) {
|
|
174
|
+
const lock = locks.get(lockId);
|
|
175
|
+
return lock === undefined ? null : lock.expires_at;
|
|
176
|
+
},
|
|
177
|
+
size() { return { held: [...locks.values()].filter((lock) => lock.state === 'held').length, recorded: locks.size, events: seq }; },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project notifications (M5, v1.3.10-beta.17 slice).
|
|
3
|
+
*
|
|
4
|
+
* Notifications answer "what happened while I was away" without a websocket: every one is an event on the project stream
|
|
5
|
+
* (live) and a row returned by the list route (catch-up), and each reader has a watermark rather than per-row read flags.
|
|
6
|
+
*
|
|
7
|
+
* Rules (docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md §4):
|
|
8
|
+
* - a notification is (project, kind, target, subject, refs, requires_action), addressed to a member or broadcast;
|
|
9
|
+
* - the same (kind, primary ref, target) inside 60s is one notification, not two;
|
|
10
|
+
* - more than 120 in a minute for one project collapses into a digest row carrying the count;
|
|
11
|
+
* - retention is 7 days or 2000 rows per project, whichever cuts first;
|
|
12
|
+
* - reading is a watermark per reader: unread = rows after the watermark; a watermark never moves backwards.
|
|
13
|
+
*/
|
|
14
|
+
export const NOTIFICATION_KINDS = Object.freeze([
|
|
15
|
+
'task.assigned', 'review.requested', 'review.decided', 'lock.conflict', 'merge.conflict',
|
|
16
|
+
'merge.merged', 'ref.updated', 'mention', 'maintenance.result', 'digest',
|
|
17
|
+
]);
|
|
18
|
+
export const DEFAULT_NOTIFICATION_LIMITS = Object.freeze({
|
|
19
|
+
dedupeWindowMs: 60 * 1000,
|
|
20
|
+
ratePerMinute: 120,
|
|
21
|
+
retentionMs: 7 * 24 * 60 * 60 * 1000,
|
|
22
|
+
maxPerProject: 2000,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export class NotificationError extends Error {
|
|
26
|
+
constructor(code, message, detail = null) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'NotificationError';
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.detail = detail;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const memberKey = (member) => (member && typeof member.kind === 'string' && typeof member.id === 'string' ? member.kind + ':' + member.id : null);
|
|
35
|
+
|
|
36
|
+
export function createProjectNotifications({ now = () => Date.now(), onEvent = null, limits = DEFAULT_NOTIFICATION_LIMITS } = {}) {
|
|
37
|
+
const rows = new Map(); // notification_id -> row
|
|
38
|
+
const dedupe = new Map(); // dedupe key -> { id, at_ms }
|
|
39
|
+
const watermarks = new Map(); // member key -> { ms, updated_at }
|
|
40
|
+
const digests = new Map(); // project_id -> { window_start_ms, id, count }
|
|
41
|
+
let counter = 0;
|
|
42
|
+
let seq = 0;
|
|
43
|
+
|
|
44
|
+
const emit = (type, payload) => {
|
|
45
|
+
seq += 1;
|
|
46
|
+
const event = { seq, type, at: new Date(now()).toISOString(), payload };
|
|
47
|
+
if (typeof onEvent === 'function') onEvent(event);
|
|
48
|
+
return event;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const primaryRef = (refs) => {
|
|
52
|
+
if (!refs || typeof refs !== 'object') return '';
|
|
53
|
+
for (const key of ['task_id', 'review_id', 'lock_id', 'merge_queue_id', 'sha256']) if (typeof refs[key] === 'string' && refs[key] !== '') return key + ':' + refs[key];
|
|
54
|
+
return '';
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const targetKey = (target) => (!target || typeof target !== 'object' ? 'none' : target.kind === 'broadcast' ? 'broadcast' : memberKey(target) ?? 'none');
|
|
58
|
+
|
|
59
|
+
/** Drop rows the retention rule no longer keeps; oldest first. Called before every write. */
|
|
60
|
+
const prune = (projectId) => {
|
|
61
|
+
const mine = [...rows.values()].filter((row) => row.project_id === projectId).sort((a, b) => a.created_at_ms - b.created_at_ms);
|
|
62
|
+
const cutoff = now() - limits.retentionMs;
|
|
63
|
+
const keep = [];
|
|
64
|
+
for (const row of mine) {
|
|
65
|
+
if (row.created_at_ms >= cutoff) keep.push(row);
|
|
66
|
+
else rows.delete(row.notification_id);
|
|
67
|
+
}
|
|
68
|
+
while (keep.length > limits.maxPerProject) {
|
|
69
|
+
const dropped = keep.shift();
|
|
70
|
+
rows.delete(dropped.notification_id);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The digest row for this minute, created on demand and updated in place. */
|
|
75
|
+
const digestFor = (projectId, target) => {
|
|
76
|
+
const windowStart = Math.floor(now() / 60000) * 60000;
|
|
77
|
+
const current = digests.get(projectId);
|
|
78
|
+
if (current !== undefined && current.window_start_ms === windowStart) return rows.get(current.id);
|
|
79
|
+
counter += 1;
|
|
80
|
+
const id = 'nt-' + String(counter).padStart(6, '0');
|
|
81
|
+
const row = {
|
|
82
|
+
notification_id: id,
|
|
83
|
+
project_id: projectId,
|
|
84
|
+
kind: 'digest',
|
|
85
|
+
target: target ?? { kind: 'broadcast' },
|
|
86
|
+
subject: 'digest',
|
|
87
|
+
refs: {},
|
|
88
|
+
requires_action: false,
|
|
89
|
+
created_at: new Date(windowStart).toISOString(),
|
|
90
|
+
created_at_ms: windowStart,
|
|
91
|
+
count: 1,
|
|
92
|
+
};
|
|
93
|
+
rows.set(id, row);
|
|
94
|
+
digests.set(projectId, { window_start_ms: windowStart, id, count: 1 });
|
|
95
|
+
emit('notification.digest', { notification_id: id, project_id: projectId, count: row.count, window_start_ms: windowStart });
|
|
96
|
+
return row;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
notify({ projectId, kind, target = { kind: 'broadcast' }, subject = null, refs = {}, requiresAction = false }) {
|
|
101
|
+
if (typeof projectId !== 'string' || projectId === '') throw new NotificationError('INVALID_PROJECT', 'project_id is required');
|
|
102
|
+
if (!NOTIFICATION_KINDS.includes(kind)) throw new NotificationError('INVALID_KIND', 'unknown notification kind', { kind });
|
|
103
|
+
if (kind !== 'digest' && (!target || typeof target !== 'object')) throw new NotificationError('INVALID_TARGET', 'target is required');
|
|
104
|
+
prune(projectId);
|
|
105
|
+
const at = now();
|
|
106
|
+
const dedupeKey = projectId + '|' + kind + '|' + targetKey(target) + '|' + primaryRef(refs);
|
|
107
|
+
const seen = dedupe.get(dedupeKey);
|
|
108
|
+
if (seen !== undefined && at - seen.at_ms <= limits.dedupeWindowMs && rows.has(seen.id)) {
|
|
109
|
+
return { ok: true, duplicate: true, notification: rows.get(seen.id) };
|
|
110
|
+
}
|
|
111
|
+
// Rate limit: one digest rows carries the overflow instead of 120 individual rows.
|
|
112
|
+
const minuteStart = at - 60000;
|
|
113
|
+
const recent = [...rows.values()].filter((row) => row.project_id === projectId && row.kind !== 'digest' && row.created_at_ms > minuteStart);
|
|
114
|
+
if (recent.length >= limits.ratePerMinute) {
|
|
115
|
+
counter += 1;
|
|
116
|
+
const digest = digestFor(projectId, target);
|
|
117
|
+
digest.count += 1;
|
|
118
|
+
digests.set(projectId, { window_start_ms: digest.created_at_ms, id: digest.notification_id, count: digest.count });
|
|
119
|
+
return { ok: true, digest: true, notification: digest };
|
|
120
|
+
}
|
|
121
|
+
counter += 1;
|
|
122
|
+
const id = 'nt-' + String(counter).padStart(6, '0');
|
|
123
|
+
const row = {
|
|
124
|
+
notification_id: id,
|
|
125
|
+
project_id: projectId,
|
|
126
|
+
kind,
|
|
127
|
+
target,
|
|
128
|
+
subject,
|
|
129
|
+
refs: { ...refs },
|
|
130
|
+
requires_action: requiresAction === true,
|
|
131
|
+
created_at: new Date(at).toISOString(),
|
|
132
|
+
created_at_ms: at,
|
|
133
|
+
};
|
|
134
|
+
rows.set(id, row);
|
|
135
|
+
dedupe.set(dedupeKey, { id, at_ms: at });
|
|
136
|
+
emit('notification.created', { notification_id: id, project_id: projectId, kind, target, subject, refs: row.refs, requires_action: row.requires_action });
|
|
137
|
+
return { ok: true, notification: row };
|
|
138
|
+
},
|
|
139
|
+
/** Notifications after a millisecond watermark, oldest first, bounded by limit. */
|
|
140
|
+
list({ projectId, sinceMs = 0, limit = 200 }) {
|
|
141
|
+
prune(projectId);
|
|
142
|
+
const slice = [...rows.values()]
|
|
143
|
+
.filter((row) => row.project_id === projectId && row.created_at_ms > sinceMs)
|
|
144
|
+
.sort((a, b) => a.created_at_ms - b.created_at_ms || a.notification_id.localeCompare(b.notification_id))
|
|
145
|
+
.slice(0, Math.max(1, limit))
|
|
146
|
+
.map((row) => ({ ...row, refs: { ...row.refs } }));
|
|
147
|
+
return { notifications: slice, next_since_ms: slice.length === 0 ? sinceMs : slice[slice.length - 1].created_at_ms };
|
|
148
|
+
},
|
|
149
|
+
/** Count and summarise what this reader has not acknowledged yet. */
|
|
150
|
+
unread({ projectId, reader }) {
|
|
151
|
+
const key = memberKey(reader);
|
|
152
|
+
if (key === null) throw new NotificationError('INVALID_READER', 'reader needs kind and id');
|
|
153
|
+
const watermark = watermarks.get(projectId + '|' + key)?.ms ?? 0;
|
|
154
|
+
const pending = [...rows.values()].filter((row) => row.project_id === projectId && row.created_at_ms > watermark);
|
|
155
|
+
return {
|
|
156
|
+
count: pending.length,
|
|
157
|
+
since_ms: watermark,
|
|
158
|
+
requires_action: pending.filter((row) => row.requires_action === true).length,
|
|
159
|
+
summary: pending.slice(-10).map((row) => ({ notification_id: row.notification_id, kind: row.kind, subject: row.subject, created_at: row.created_at })),
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
/** Advance a reader's watermark; it never moves backwards. */
|
|
163
|
+
markRead({ projectId, reader, watermarkMs = null }) {
|
|
164
|
+
const key = memberKey(reader);
|
|
165
|
+
if (key === null) throw new NotificationError('INVALID_READER', 'reader needs kind and id');
|
|
166
|
+
if (!Number.isFinite(watermarkMs)) throw new NotificationError('INVALID_WATERMARK', 'watermark_ms must be finite');
|
|
167
|
+
const storageKey = projectId + '|' + key;
|
|
168
|
+
const previous = watermarks.get(storageKey)?.ms ?? 0;
|
|
169
|
+
const next = Math.max(previous, watermarkMs);
|
|
170
|
+
watermarks.set(storageKey, { ms: next, updated_at: new Date(now()).toISOString() });
|
|
171
|
+
return { ok: true, watermark_ms: next, advanced: next !== previous };
|
|
172
|
+
},
|
|
173
|
+
readWatermark({ projectId, reader }) {
|
|
174
|
+
const key = memberKey(reader);
|
|
175
|
+
return watermarks.get(projectId + '|' + key)?.ms ?? 0;
|
|
176
|
+
},
|
|
177
|
+
size({ projectId = null } = {}) {
|
|
178
|
+
const all = [...rows.values()];
|
|
179
|
+
return { rows: projectId === null ? all.length : all.filter((row) => row.project_id === projectId).length, events: seq };
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|