amalgm 0.1.238 → 0.1.240

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/lib/shared-realtime-tunnel.js +22 -14
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
  4. package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
  5. package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
  6. package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
  7. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
  8. package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
  9. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
  10. package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
  11. package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
  12. package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
  13. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
  14. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
  15. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
  16. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
  17. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
  18. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
  19. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
  20. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
  21. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
  22. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
  23. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
  24. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
  25. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
  26. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
  27. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
  28. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
  29. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +81 -3
  30. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +114 -8
  31. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
  33. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
  34. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
  35. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
  36. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
  37. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
  38. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
  39. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
  40. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
  41. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
  42. package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
  43. package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
  44. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
  45. package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
  46. package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
  47. package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
  48. package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
  49. package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
  50. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
  51. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  52. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  53. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
  54. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
  55. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
  56. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
  57. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
  58. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
  59. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
  60. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
  61. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
  63. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
  64. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
  65. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
  66. package/runtime/scripts/amalgm-mcp/workspace/objects.js +119 -54
  67. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
  68. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
  69. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
  70. package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
  71. package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
  72. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
  73. package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
  74. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
  75. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
  76. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
  77. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
  78. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
  79. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
  80. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
  81. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
  82. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
  83. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
  84. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
  85. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
  86. package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
@@ -1,84 +1,528 @@
1
1
  'use strict';
2
2
 
3
+ /**
4
+ * The one authoritative stream for a code project.
5
+ *
6
+ * Clients publish deltas. Only an officially numbered cloud mutation changes
7
+ * this snapshot. A local outbox row is intent, never authority.
8
+ */
9
+
3
10
  const crypto = require('crypto');
4
11
  const fs = require('fs');
5
12
  const { appendStateEvent } = require('../state/events');
6
13
  const { openLocalDb } = require('../state/db');
14
+ const durable = require('./durable');
7
15
 
8
- const STREAM_CONTRACT = 'code-project-stream@1';
9
- const WALL_CONTRACT = 'code-project@6';
16
+ const STREAM_CONTRACT = 'code-project-stream@2';
17
+ const WALL_CONTRACT = 'code-project@7';
18
+ const CARD_CONTRACT = 'repo-card@3';
19
+ const CHECKPOINT_CONTRACT = 'repo-checkpoint@11';
20
+ const BUNDLE_CONTRACT = 'git-bundle-chunks@2';
21
+ const PAYLOAD_CONTRACT = 'opaque-chunks@2';
10
22
  const LOCAL_RESOURCE_PREFIX = 'code-project:';
23
+ const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
24
+ const SHA256 = /^[0-9a-f]{64}$/;
25
+ const MUTATION_ID = /^[A-Za-z0-9_-]{16,160}$/;
26
+ const MAX_CHUNKS = 4096;
27
+ const MAX_CHUNK_BYTES = 512 * 1024;
28
+ const MAX_BRANCHES = 4096;
11
29
 
12
- function invalid(message, statusCode = 400) {
13
- return Object.assign(new Error(message), { statusCode });
30
+ function invalid(message, statusCode = 400, code = null) {
31
+ const error = Object.assign(new Error(message), { statusCode });
32
+ if (code) error.code = code;
33
+ return error;
14
34
  }
15
35
 
16
- function emptySnapshot(project) {
17
- return { schemaVersion: 1, project, version: 0, records: [] };
36
+ function exactObject(input, keys, label) {
37
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
38
+ throw invalid(`${label} is invalid`, 409);
39
+ }
40
+ const actual = Object.keys(input).sort();
41
+ const expected = [...keys].sort();
42
+ if (
43
+ actual.length !== expected.length
44
+ || actual.some((key, index) => key !== expected[index])
45
+ ) throw invalid(`${label} has unexpected fields`, 409);
46
+ return input;
18
47
  }
19
48
 
20
- function localResource(resourceId) {
21
- return `${LOCAL_RESOURCE_PREFIX}${resourceId}`;
49
+ function nonEmptyString(value, label) {
50
+ if (typeof value !== 'string' || !value) throw invalid(`${label} is invalid`, 409);
51
+ return value;
22
52
  }
23
53
 
24
- function normalizeRecord(input, project, official = false) {
54
+ function branchName(value) {
55
+ const components = typeof value === 'string' ? value.split('/') : [];
25
56
  if (
26
- !input
27
- || input.wall !== WALL_CONTRACT
28
- || input.project !== project
29
- || typeof input.machine !== 'string'
30
- || !input.machine
31
- || typeof input.mutationId !== 'string'
32
- || !input.mutationId
33
- || typeof input.publishedAt !== 'string'
34
- || !Number.isFinite(Date.parse(input.publishedAt))
35
- || !Array.isArray(input.cards)
36
- ) throw invalid('code project record is invalid', 409);
37
- const version = Number(input.version);
38
- if (official && (!Number.isSafeInteger(version) || version < 1)) {
39
- throw invalid('code project record has no official version', 409);
57
+ typeof value !== 'string'
58
+ || value.length < 1
59
+ || value.length > 1024
60
+ || value === '@'
61
+ || value === 'HEAD'
62
+ || value.startsWith('-')
63
+ || value.endsWith('.')
64
+ || value.includes('..')
65
+ || value.includes('@{')
66
+ || value.includes('[')
67
+ || /[\u0000-\u0020\u007f~^:?*\\]/.test(value)
68
+ || components.some((component) => (
69
+ !component
70
+ || component.startsWith('.')
71
+ || component.endsWith('.lock')
72
+ ))
73
+ ) throw invalid('branch name is invalid', 409);
74
+ return value;
75
+ }
76
+
77
+ function mutationId(value, label) {
78
+ if (!MUTATION_ID.test(String(value || ''))) throw invalid(`${label} is invalid`, 409);
79
+ return value;
80
+ }
81
+
82
+ function normalizeChunks(input, resourceId, kind, contract) {
83
+ exactObject(
84
+ input,
85
+ ['kind', 'contract', 'resourceId', 'checksum', 'bytes', 'chunks'],
86
+ `${kind} locator`,
87
+ );
88
+ if (
89
+ input.kind !== kind
90
+ || input.contract !== contract
91
+ || input.resourceId !== resourceId
92
+ || !SHA256.test(String(input.checksum || ''))
93
+ || !Number.isSafeInteger(input.bytes)
94
+ || input.bytes < 1
95
+ || !Array.isArray(input.chunks)
96
+ || input.chunks.length < 1
97
+ || input.chunks.length > MAX_CHUNKS
98
+ ) throw invalid(`${kind} locator is invalid`, 409);
99
+ const chunks = input.chunks.map((chunk) => {
100
+ exactObject(chunk, ['sha256', 'bytes'], `${kind} chunk`);
101
+ if (
102
+ !SHA256.test(String(chunk.sha256 || ''))
103
+ || !Number.isSafeInteger(chunk.bytes)
104
+ || chunk.bytes < 1
105
+ || chunk.bytes > MAX_CHUNK_BYTES
106
+ ) throw invalid(`${kind} chunk is invalid`, 409);
107
+ return { sha256: chunk.sha256, bytes: chunk.bytes };
108
+ });
109
+ if (chunks.reduce((sum, chunk) => sum + chunk.bytes, 0) !== input.bytes) {
110
+ throw invalid(`${kind} locator byte count is invalid`, 409);
40
111
  }
112
+ const checksum = crypto.createHash('sha256').update(Buffer.from(JSON.stringify(chunks))).digest('hex');
113
+ if (checksum !== input.checksum) throw invalid(`${kind} locator checksum is invalid`, 409);
41
114
  return {
42
- ...input,
43
- ...(Number.isSafeInteger(version) && version > 0 ? { version } : {}),
115
+ kind,
116
+ contract,
117
+ resourceId,
118
+ checksum: input.checksum,
119
+ bytes: input.bytes,
120
+ chunks,
121
+ };
122
+ }
123
+
124
+ function normalizeBundle(input, resourceId, oid) {
125
+ exactObject(
126
+ input,
127
+ ['kind', 'contract', 'resourceId', 'oid', 'ref', 'checksum', 'bytes', 'chunks'],
128
+ 'bundle locator',
129
+ );
130
+ if (
131
+ input.kind !== 'bundle'
132
+ || input.contract !== BUNDLE_CONTRACT
133
+ || input.resourceId !== resourceId
134
+ || input.oid !== oid
135
+ || input.ref !== `refs/amalgm/exports/${oid}`
136
+ ) throw invalid('bundle locator is invalid', 409);
137
+ const common = normalizeChunks(
138
+ {
139
+ kind: 'bundle',
140
+ contract: BUNDLE_CONTRACT,
141
+ resourceId: input.resourceId,
142
+ checksum: input.checksum,
143
+ bytes: input.bytes,
144
+ chunks: input.chunks,
145
+ },
146
+ resourceId,
147
+ 'bundle',
148
+ BUNDLE_CONTRACT,
149
+ );
150
+ return { ...common, oid, ref: input.ref };
151
+ }
152
+
153
+ function normalizePayload(input, resourceId) {
154
+ return normalizeChunks(input, resourceId, 'chunks', PAYLOAD_CONTRACT);
155
+ }
156
+
157
+ function normalizeCheckpointValue(input, resourceId, baseOid = null) {
158
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
159
+ throw invalid('checkpoint value is invalid', 409);
160
+ }
161
+ const revision = input.revision;
162
+ if (!(revision === null || SHA256.test(String(revision || '')))) {
163
+ throw invalid('checkpoint revision is invalid', 409);
164
+ }
165
+ if (Object.hasOwn(input, 'sections')) {
166
+ exactObject(input, ['revision', 'sections'], 'checkpoint value');
167
+ exactObject(input.sections, ['index', 'worktree'], 'checkpoint sections');
168
+ const normalizeLayer = (layer) => {
169
+ if (!Array.isArray(input.sections[layer])) {
170
+ throw invalid('checkpoint sections are invalid', 409);
171
+ }
172
+ return input.sections[layer].map((section) => {
173
+ if (Object.hasOwn(section || {}, 'text')) {
174
+ exactObject(section, ['text'], 'checkpoint section');
175
+ if (typeof section.text !== 'string' || section.text.length === 0) {
176
+ throw invalid('checkpoint section is invalid', 409);
177
+ }
178
+ return { text: section.text };
179
+ }
180
+ exactObject(section, ['payload'], 'checkpoint section');
181
+ return { payload: normalizePayload(section.payload, resourceId) };
182
+ });
183
+ };
184
+ const normalized = {
185
+ revision,
186
+ sections: {
187
+ index: normalizeLayer('index'),
188
+ worktree: normalizeLayer('worktree'),
189
+ },
190
+ };
191
+ if (require('./checkpoint').revisionOfValue(baseOid, normalized) !== revision) {
192
+ throw invalid('checkpoint revision is invalid', 409);
193
+ }
194
+ return normalized;
195
+ }
196
+ exactObject(input, ['revision', 'indexDiff', 'worktreeDiff'], 'checkpoint value');
197
+ if (typeof input.indexDiff !== 'string' || typeof input.worktreeDiff !== 'string') {
198
+ throw invalid('checkpoint value is invalid', 409);
199
+ }
200
+ if (
201
+ baseOid
202
+ && require('./checkpoint').revisionOfValue(baseOid, input) !== revision
203
+ ) throw invalid('checkpoint revision is invalid', 409);
204
+ return {
205
+ revision,
206
+ indexDiff: input.indexDiff,
207
+ worktreeDiff: input.worktreeDiff,
208
+ };
209
+ }
210
+
211
+ function normalizeHead(input) {
212
+ exactObject(input, ['branch', 'oid', 'detached'], 'card head');
213
+ if (
214
+ !(input.branch === null || branchName(input.branch))
215
+ || !SHA.test(String(input.oid || ''))
216
+ || typeof input.detached !== 'boolean'
217
+ || (input.detached && input.branch !== null)
218
+ || (!input.detached && !input.branch)
219
+ ) throw invalid('card head is invalid', 409);
220
+ return { branch: input.branch, oid: input.oid, detached: input.detached };
221
+ }
222
+
223
+ function normalizeBranch(input, resourceId) {
224
+ exactObject(input, ['name', 'oid', 'objects'], 'card branch');
225
+ const name = branchName(input.name);
226
+ if (!SHA.test(String(input.oid || ''))) throw invalid('branch oid is invalid', 409);
227
+ return {
228
+ name,
229
+ oid: input.oid,
230
+ objects: normalizeBundle(input.objects, resourceId, input.oid),
231
+ };
232
+ }
233
+
234
+ function normalizeCheckpoint(input, resourceId, expectedHead = null) {
235
+ exactObject(input, ['checkpoint', 'baseOid', 'branch', 'value'], 'card checkpoint');
236
+ if (
237
+ input.checkpoint !== CHECKPOINT_CONTRACT
238
+ || !SHA.test(String(input.baseOid || ''))
239
+ || !(input.branch === null || branchName(input.branch))
240
+ || (expectedHead && (
241
+ input.baseOid !== expectedHead.oid
242
+ || input.branch !== expectedHead.branch
243
+ ))
244
+ ) throw invalid('card checkpoint is invalid', 409);
245
+ return {
246
+ checkpoint: CHECKPOINT_CONTRACT,
247
+ baseOid: input.baseOid,
248
+ branch: input.branch,
249
+ value: normalizeCheckpointValue(input.value, resourceId, input.baseOid),
250
+ };
251
+ }
252
+
253
+ function normalizeCardSet(input, resourceId) {
254
+ exactObject(input, ['kind', 'card', 'head', 'objects', 'branches', 'checkpoint'], 'card.set');
255
+ if (input.kind !== 'card.set' || input.card !== CARD_CONTRACT) {
256
+ throw invalid('card.set is invalid', 409);
257
+ }
258
+ const head = normalizeHead(input.head);
259
+ const branches = Array.isArray(input.branches)
260
+ ? input.branches.map((branch) => normalizeBranch(branch, resourceId))
261
+ : null;
262
+ if (!branches) throw invalid('card branches are invalid', 409);
263
+ if (branches.length > MAX_BRANCHES) throw invalid('card has too many branches', 409);
264
+ const names = new Set();
265
+ for (const branch of branches) {
266
+ if (names.has(branch.name)) throw invalid('card repeats a branch', 409);
267
+ names.add(branch.name);
268
+ }
269
+ if (!head.detached && !branches.some((branch) => (
270
+ branch.name === head.branch && branch.oid === head.oid
271
+ ))) throw invalid('card branch map does not contain HEAD', 409);
272
+ return {
273
+ kind: 'card.set',
274
+ card: CARD_CONTRACT,
275
+ head,
276
+ objects: normalizeBundle(input.objects, resourceId, head.oid),
277
+ branches: branches.sort((left, right) => left.name.localeCompare(right.name)),
278
+ checkpoint: normalizeCheckpoint(input.checkpoint, resourceId, head),
279
+ };
280
+ }
281
+
282
+ function normalizeCheckpointSet(input, resourceId) {
283
+ exactObject(
284
+ input,
285
+ ['kind', 'checkpoint', 'baseOid', 'branch', 'from', 'to'],
286
+ 'checkpoint.set',
287
+ );
288
+ if (
289
+ input.kind !== 'checkpoint.set'
290
+ || input.checkpoint !== CHECKPOINT_CONTRACT
291
+ || !SHA.test(String(input.baseOid || ''))
292
+ || !(input.branch === null || branchName(input.branch))
293
+ ) throw invalid('checkpoint.set is invalid', 409);
294
+ return {
295
+ kind: 'checkpoint.set',
296
+ checkpoint: CHECKPOINT_CONTRACT,
297
+ baseOid: input.baseOid,
298
+ branch: input.branch,
299
+ from: normalizeCheckpointValue(input.from, resourceId, input.baseOid),
300
+ to: normalizeCheckpointValue(input.to, resourceId, input.baseOid),
301
+ };
302
+ }
303
+
304
+ function normalizeDelta(input, resourceId) {
305
+ let normalized;
306
+ if (input?.kind === 'card.set') normalized = normalizeCardSet(input, resourceId);
307
+ else if (input?.kind === 'checkpoint.set') normalized = normalizeCheckpointSet(input, resourceId);
308
+ else throw invalid('code project delta is invalid', 409);
309
+ const chunks = new Map();
310
+ const visit = (value) => {
311
+ if (!value || typeof value !== 'object') return;
312
+ if (Array.isArray(value)) {
313
+ value.forEach(visit);
314
+ return;
315
+ }
316
+ if (Array.isArray(value.chunks)) {
317
+ value.chunks.forEach((chunk) => {
318
+ const previous = chunks.get(chunk.sha256);
319
+ if (previous !== undefined && previous !== chunk.bytes) {
320
+ throw invalid('one code chunk claims two sizes', 409);
321
+ }
322
+ chunks.set(chunk.sha256, chunk.bytes);
323
+ });
324
+ }
325
+ Object.values(value).forEach(visit);
326
+ };
327
+ visit(normalized);
328
+ if (chunks.size > MAX_CHUNKS) throw invalid('code publication has too many chunks', 409);
329
+ return normalized;
330
+ }
331
+
332
+ function normalizeOperation(input, resourceId) {
333
+ exactObject(input, ['kind', 'project', 'delta'], 'code.publish operation');
334
+ if (input.kind !== 'code.publish' || input.project !== resourceId) {
335
+ throw invalid('code.publish operation is invalid', 409);
336
+ }
337
+ return {
338
+ kind: 'code.publish',
339
+ project: resourceId,
340
+ delta: normalizeDelta(input.delta, resourceId),
341
+ };
342
+ }
343
+
344
+ function stampCard(delta, stamp) {
345
+ return {
346
+ card: CARD_CONTRACT,
347
+ head: delta.head,
348
+ objects: delta.objects,
349
+ branches: delta.branches,
350
+ ...stamp,
351
+ };
352
+ }
353
+
354
+ function stampCheckpoint(checkpoint, stamp) {
355
+ return { ...checkpoint, ...stamp };
356
+ }
357
+
358
+ function sameValue(left, right) {
359
+ return JSON.stringify(left) === JSON.stringify(right);
360
+ }
361
+
362
+ function fold(snapshotInput, operationInput, stampInput) {
363
+ const snapshot = normalizeSnapshot(snapshotInput, operationInput?.project, true);
364
+ const operation = normalizeOperation(operationInput, snapshot.project);
365
+ const version = Number(stampInput?.version);
366
+ const machine = nonEmptyString(stampInput?.machine, 'mutation machine');
367
+ const officialMutationId = mutationId(stampInput?.mutationId, 'mutation id');
368
+ if (!Number.isSafeInteger(version) || version < 1) {
369
+ throw invalid('mutation version is invalid', 409);
370
+ }
371
+ // Snapshot writes precede cursor writes. Replaying after a crash is silent.
372
+ if (snapshot.version >= version) return snapshot;
373
+ if (version !== snapshot.version + 1) {
374
+ throw invalid(
375
+ `code project gap: expected ${snapshot.version + 1}, received ${version}`,
376
+ 409,
377
+ 'shared_mutation_gap',
378
+ );
379
+ }
380
+ const stamp = { machine, mutationId: officialMutationId, version };
381
+ if (operation.delta.kind === 'card.set') {
382
+ return {
383
+ schemaVersion: 2,
384
+ project: snapshot.project,
385
+ version,
386
+ card: stampCard(operation.delta, stamp),
387
+ checkpoint: stampCheckpoint(operation.delta.checkpoint, stamp),
388
+ };
389
+ }
390
+ const delta = operation.delta;
391
+ if (
392
+ !snapshot.card
393
+ || snapshot.card.head.oid !== delta.baseOid
394
+ || snapshot.card.head.branch !== delta.branch
395
+ || !snapshot.checkpoint
396
+ || snapshot.checkpoint.baseOid !== delta.baseOid
397
+ || snapshot.checkpoint.branch !== delta.branch
398
+ || !sameValue(snapshot.checkpoint.value, delta.from)
399
+ ) throw invalid('code_project_stale_base', 409, 'code_project_stale_base');
400
+ return {
401
+ ...snapshot,
402
+ version,
403
+ checkpoint: stampCheckpoint({
404
+ checkpoint: CHECKPOINT_CONTRACT,
405
+ baseOid: delta.baseOid,
406
+ branch: delta.branch,
407
+ value: delta.to,
408
+ }, stamp),
409
+ };
410
+ }
411
+
412
+ function emptySnapshot(resourceId) {
413
+ return {
414
+ schemaVersion: 2,
415
+ project: resourceId,
416
+ version: 0,
417
+ card: null,
418
+ checkpoint: null,
419
+ };
420
+ }
421
+
422
+ function normalizeStampedCard(input, resourceId, snapshotVersion) {
423
+ exactObject(
424
+ input,
425
+ ['card', 'head', 'objects', 'branches', 'machine', 'mutationId', 'version'],
426
+ 'official card',
427
+ );
428
+ const version = Number(input.version);
429
+ if (
430
+ input.card !== CARD_CONTRACT
431
+ || !Number.isSafeInteger(version)
432
+ || version < 1
433
+ || version > snapshotVersion
434
+ ) throw invalid('official card stamp is invalid', 409);
435
+ const delta = normalizeCardSet({
436
+ kind: 'card.set',
437
+ card: input.card,
438
+ head: input.head,
439
+ objects: input.objects,
440
+ branches: input.branches,
441
+ checkpoint: {
442
+ checkpoint: CHECKPOINT_CONTRACT,
443
+ baseOid: input.head.oid,
444
+ branch: input.head.branch,
445
+ value: { revision: null, indexDiff: '', worktreeDiff: '' },
446
+ },
447
+ }, resourceId);
448
+ return {
449
+ card: CARD_CONTRACT,
450
+ head: delta.head,
451
+ objects: delta.objects,
452
+ branches: delta.branches,
453
+ machine: nonEmptyString(input.machine, 'official card machine'),
454
+ mutationId: mutationId(input.mutationId, 'official card mutation'),
455
+ version,
456
+ };
457
+ }
458
+
459
+ function normalizeStampedCheckpoint(input, resourceId, snapshotVersion) {
460
+ exactObject(
461
+ input,
462
+ ['checkpoint', 'baseOid', 'branch', 'value', 'machine', 'mutationId', 'version'],
463
+ 'official checkpoint',
464
+ );
465
+ const version = Number(input.version);
466
+ if (
467
+ !Number.isSafeInteger(version)
468
+ || version < 1
469
+ || version > snapshotVersion
470
+ ) throw invalid('official checkpoint stamp is invalid', 409);
471
+ return {
472
+ ...normalizeCheckpoint({
473
+ checkpoint: input.checkpoint,
474
+ baseOid: input.baseOid,
475
+ branch: input.branch,
476
+ value: input.value,
477
+ }, resourceId),
478
+ machine: nonEmptyString(input.machine, 'official checkpoint machine'),
479
+ mutationId: mutationId(input.mutationId, 'official checkpoint mutation'),
480
+ version,
44
481
  };
45
482
  }
46
483
 
47
- function normalizeSnapshot(input, project = null, official = false) {
484
+ function normalizeSnapshot(input, resourceId = null, official = false) {
485
+ exactObject(input, ['schemaVersion', 'project', 'version', 'card', 'checkpoint'], 'code project snapshot');
48
486
  if (
49
- !input
50
- || input.schemaVersion !== 1
487
+ input.schemaVersion !== 2
51
488
  || typeof input.project !== 'string'
52
489
  || !input.project
490
+ || !require('./tree/identity').isUuid(input.project)
491
+ || (resourceId && input.project !== resourceId)
53
492
  || !Number.isSafeInteger(Number(input.version))
54
493
  || Number(input.version) < 0
55
- || !Array.isArray(input.records)
56
- || (project && input.project !== project)
57
494
  ) throw invalid('code project snapshot is invalid', 409);
58
- const machines = new Set();
59
- const records = input.records.map((record) => {
60
- const normalized = normalizeRecord(record, input.project, official);
61
- if (official && normalized.version > Number(input.version)) {
62
- throw invalid('code project record is newer than its snapshot', 409);
63
- }
64
- if (machines.has(normalized.machine)) {
65
- throw invalid('code project snapshot repeats a machine', 409);
495
+ const version = Number(input.version);
496
+ if (version === 0) {
497
+ if (input.card !== null || input.checkpoint !== null) {
498
+ throw invalid('empty code project snapshot is invalid', 409);
66
499
  }
67
- machines.add(normalized.machine);
68
- return normalized;
69
- });
500
+ return emptySnapshot(input.project);
501
+ }
502
+ if (!official || !input.card || !input.checkpoint) {
503
+ throw invalid('official code project snapshot is incomplete', 409);
504
+ }
505
+ const card = normalizeStampedCard(input.card, input.project, version);
506
+ const checkpoint = normalizeStampedCheckpoint(input.checkpoint, input.project, version);
507
+ if (
508
+ checkpoint.version !== version
509
+ || card.version > checkpoint.version
510
+ || checkpoint.baseOid !== card.head.oid
511
+ || checkpoint.branch !== card.head.branch
512
+ ) throw invalid('official checkpoint does not match the card', 409);
70
513
  return {
71
- schemaVersion: 1,
514
+ schemaVersion: 2,
72
515
  project: input.project,
73
- version: Number(input.version),
74
- records,
516
+ version,
517
+ card,
518
+ checkpoint,
75
519
  };
76
520
  }
77
521
 
78
- function readSnapshot(file, project) {
79
- if (!fs.existsSync(file) || fs.statSync(file).size === 0) return emptySnapshot(project);
522
+ function readSnapshot(file, resourceId) {
523
+ if (!fs.existsSync(file) || fs.statSync(file).size === 0) return emptySnapshot(resourceId);
80
524
  try {
81
- return normalizeSnapshot(JSON.parse(fs.readFileSync(file, 'utf8')), project);
525
+ return normalizeSnapshot(JSON.parse(fs.readFileSync(file, 'utf8')), resourceId, true);
82
526
  } catch (error) {
83
527
  if (error?.statusCode) throw error;
84
528
  throw invalid('code project snapshot is not JSON', 409);
@@ -86,114 +530,47 @@ function readSnapshot(file, project) {
86
530
  }
87
531
 
88
532
  function writeSnapshot(file, snapshot) {
89
- fs.mkdirSync(require('path').dirname(file), { recursive: true });
90
- fs.writeFileSync(file, `${JSON.stringify(snapshot)}\n`);
91
- }
92
-
93
- function applyRecord(snapshot, record, version = null) {
94
- const next = normalizeRecord(
95
- version ? { ...record, version } : record,
96
- snapshot.project,
97
- false,
98
- );
99
- const records = new Map(snapshot.records.map((entry) => [entry.machine, entry]));
100
- records.set(next.machine, next);
101
- return {
102
- ...snapshot,
103
- version: Number.isSafeInteger(version)
104
- ? Math.max(snapshot.version, version)
105
- : snapshot.version,
106
- records: [...records.values()].sort((left, right) => (
107
- left.machine.localeCompare(right.machine)
108
- )),
109
- };
110
- }
111
-
112
- function records(project) {
113
- const snapshot = readSnapshot(project.file, project.repoKey);
114
- let changed = false;
115
- const sealed = snapshot.records.map((record) => {
116
- if (record.version) return record;
117
- const mutation = require('../state/mutations').getMutation(
118
- `resource:${project.resourceId}`,
119
- record.mutationId,
120
- );
121
- const version = mutation?.officialCloudVersion;
122
- if (!Number.isSafeInteger(version) || version < 1) return record;
123
- changed = true;
124
- return { ...record, version };
125
- });
126
- if (changed) writeSnapshot(project.file, {
127
- ...snapshot,
128
- version: Math.max(snapshot.version, ...sealed.map((record) => record.version || 0)),
129
- records: sealed,
130
- });
131
- return sealed;
533
+ durable.writeJson(file, normalizeSnapshot(snapshot, snapshot.project, true));
132
534
  }
133
535
 
134
- function publish(project, record) {
536
+ function publish(project, deltaInput, mutationIdInput) {
135
537
  const replica = require('../state/replicas').getReplicaByResourceId(project.resourceId);
136
538
  if (!replica || replica.contract !== STREAM_CONTRACT) {
137
539
  throw invalid('code project stream is not attached', 409);
138
540
  }
541
+ const operation = normalizeOperation({
542
+ kind: 'code.publish',
543
+ project: project.resourceId,
544
+ delta: deltaInput,
545
+ }, project.resourceId);
546
+ const publicationMutationId = mutationId(mutationIdInput, 'publication mutation');
139
547
  return require('../state/mutations').commitLocalMutation({
140
548
  channelId: `resource:${project.resourceId}`,
141
- localResource: localResource(project.resourceId),
549
+ localResource: `${LOCAL_RESOURCE_PREFIX}${project.resourceId}`,
142
550
  sharedResourceId: project.resourceId,
143
- mutationId: record.mutationId,
551
+ mutationId: publicationMutationId,
144
552
  deviceId: require('./identity').machineId(),
145
553
  authorityEpoch: replica.authorityEpoch,
146
554
  baseCloudVersion: replica.appliedVersion,
147
555
  contract: STREAM_CONTRACT,
148
- schemaVersion: 1,
556
+ schemaVersion: 2,
149
557
  operationKind: 'code.publish',
150
- operation: { kind: 'code.publish', record },
558
+ operation,
151
559
  }, {
152
- materialize() {
153
- writeSnapshot(
154
- project.file,
155
- applyRecord(readSnapshot(project.file, project.repoKey), record),
156
- );
157
- },
560
+ // Outbound intent deliberately does not mutate the official snapshot.
158
561
  event: {
159
- resource: localResource(project.resourceId),
562
+ resource: `${LOCAL_RESOURCE_PREFIX}${project.resourceId}`,
160
563
  op: 'update',
161
564
  id: project.file,
162
565
  sharedResourceId: project.resourceId,
163
- value: record,
164
- source: 'code-project:local',
566
+ value: operation.delta,
567
+ source: 'code-project:outbox',
165
568
  },
166
569
  });
167
570
  }
168
571
 
169
- function seal(project, mutationId, version) {
170
- if (!Number.isSafeInteger(Number(version)) || Number(version) < 1) return false;
171
- const snapshot = readSnapshot(project.file, project.repoKey);
172
- let changed = false;
173
- const records = snapshot.records.map((record) => {
174
- if (record.mutationId !== mutationId || record.version === Number(version)) return record;
175
- changed = true;
176
- return { ...record, version: Number(version) };
177
- });
178
- if (!changed) return false;
179
- writeSnapshot(project.file, {
180
- ...snapshot,
181
- version: Math.max(snapshot.version, Number(version)),
182
- records,
183
- });
184
- appendStateEvent({
185
- resource: localResource(project.resourceId),
186
- op: 'update',
187
- id: project.file,
188
- sharedResourceId: project.resourceId,
189
- resourceVersion: Number(version),
190
- source: 'code-project:ack',
191
- });
192
- return true;
193
- }
194
-
195
572
  function decodeSnapshot(input) {
196
- if (input?.contract !== STREAM_CONTRACT || Number(input?.schemaVersion) !== 1) {
573
+ if (input?.contract !== STREAM_CONTRACT || Number(input?.schemaVersion) !== 2) {
197
574
  throw invalid('unsupported code project stream', 409);
198
575
  }
199
576
  const bytes = Buffer.from(String(input.snapshotBase64 || ''), 'base64');
@@ -207,72 +584,122 @@ function decodeSnapshot(input) {
207
584
  } catch {
208
585
  throw invalid('code project snapshot must be JSON', 409);
209
586
  }
210
- const snapshot = normalizeSnapshot(parsed, null, true);
587
+ const snapshot = normalizeSnapshot(parsed, input.resourceId, true);
211
588
  if (snapshot.version !== Number(input.snapshotVersion)) {
212
589
  throw invalid('code project snapshot version mismatch', 409);
213
590
  }
214
- return { bytes, snapshot };
591
+ return { snapshot };
215
592
  }
216
593
 
217
594
  function installSnapshot(file, input, options = {}) {
218
595
  const database = options.database || openLocalDb();
219
- const { snapshot } = decodeSnapshot(input);
220
596
  const replicas = require('../state/replicas');
597
+ const envelope = replicas.validateSnapshotEnvelope(input, {
598
+ contract: STREAM_CONTRACT,
599
+ schemaVersion: 2,
600
+ });
601
+ const { snapshot } = decodeSnapshot(input);
221
602
  const existing = replicas.getReplicaByResourceId(input.resourceId, { database });
222
603
  if (existing && existing.localPath !== file) {
223
604
  throw invalid('code project stream is bound to another local file', 409);
224
605
  }
225
- let installed = snapshot;
226
- if (existing) {
227
- for (const mutation of require('../state/mutations').listPendingCloudMutations(
228
- input.resourceId,
229
- { database },
230
- )) {
231
- if (mutation.contract !== STREAM_CONTRACT || mutation.operationKind !== 'code.publish') continue;
232
- installed = applyRecord(installed, mutation.operation?.record);
606
+ const current = fs.existsSync(file)
607
+ ? readSnapshot(file, input.resourceId)
608
+ : emptySnapshot(input.resourceId);
609
+ if (snapshot.version < current.version) {
610
+ const checked = existing
611
+ ? replicas.advanceReplicaSnapshot({
612
+ resourceId: envelope.resourceId,
613
+ snapshotVersion: envelope.snapshotVersion,
614
+ snapshotChecksum: input.snapshotChecksum,
615
+ authorityEpoch: envelope.authorityEpoch,
616
+ }, { database })
617
+ : existing;
618
+ return { replica: checked, duplicate: true, ignored: true };
619
+ }
620
+ if (snapshot.version === current.version) {
621
+ if (JSON.stringify(snapshot) !== JSON.stringify(current)) {
622
+ throw invalid(
623
+ 'equal code project snapshot versions contain different state',
624
+ 409,
625
+ 'code_project_snapshot_mismatch',
626
+ );
627
+ }
628
+ if (existing) {
629
+ const replica = replicas.advanceReplicaSnapshot({
630
+ resourceId: input.resourceId,
631
+ snapshotVersion: envelope.snapshotVersion,
632
+ snapshotChecksum: input.snapshotChecksum,
633
+ authorityEpoch: envelope.authorityEpoch,
634
+ }, { database });
635
+ completeInstalledSnapshot(input.resourceId, snapshot.version, database);
636
+ return { replica, duplicate: true };
233
637
  }
234
638
  }
235
- writeSnapshot(file, installed);
639
+ writeSnapshot(file, snapshot);
236
640
  const registered = existing
237
641
  ? {
238
642
  replica: replicas.advanceReplicaSnapshot({
239
643
  resourceId: input.resourceId,
240
- snapshotVersion: input.snapshotVersion,
644
+ snapshotVersion: envelope.snapshotVersion,
241
645
  snapshotChecksum: input.snapshotChecksum,
242
- authorityEpoch: input.authorityEpoch,
646
+ authorityEpoch: envelope.authorityEpoch,
243
647
  }, { database }),
244
648
  }
245
649
  : replicas.registerReplica({
246
650
  resourceId: input.resourceId,
247
- localResource: localResource(input.resourceId),
651
+ localResource: `${LOCAL_RESOURCE_PREFIX}${input.resourceId}`,
248
652
  localPath: file,
249
653
  contract: STREAM_CONTRACT,
250
- schemaVersion: 1,
251
- authorityEpoch: input.authorityEpoch,
252
- appliedVersion: input.snapshotVersion,
253
- snapshotVersion: input.snapshotVersion,
654
+ schemaVersion: 2,
655
+ authorityEpoch: envelope.authorityEpoch,
656
+ appliedVersion: envelope.snapshotVersion,
657
+ snapshotVersion: envelope.snapshotVersion,
254
658
  snapshotChecksum: input.snapshotChecksum,
255
659
  }, { database });
660
+ completeInstalledSnapshot(input.resourceId, snapshot.version, database);
256
661
  appendStateEvent({
257
- resource: localResource(input.resourceId),
662
+ resource: `${LOCAL_RESOURCE_PREFIX}${input.resourceId}`,
258
663
  op: 'update',
259
664
  id: file,
260
665
  sharedResourceId: input.resourceId,
261
- resourceVersion: Number(input.snapshotVersion),
666
+ resourceVersion: envelope.snapshotVersion,
262
667
  source: 'code-project:snapshot',
263
668
  });
264
669
  return registered;
265
670
  }
266
671
 
672
+ function completeInstalledSnapshot(resourceId, through, database) {
673
+ require('../state/mutations').completeOfficialSnapshotFold(
674
+ resourceId,
675
+ through,
676
+ { database },
677
+ );
678
+ const project = require('./wall').projectForResource(resourceId);
679
+ if (!project) return;
680
+ const intents = require('./intent');
681
+ const held = intents.readIntent(project);
682
+ const acknowledgement = held
683
+ ? intents.officialAcknowledgement(project, held)
684
+ : null;
685
+ if (acknowledgement && acknowledgement.version <= through) {
686
+ intents.markPublicationFolded(
687
+ project,
688
+ acknowledgement.mutationId,
689
+ acknowledgement.version,
690
+ held.publication.numberedAt,
691
+ );
692
+ }
693
+ }
694
+
267
695
  function applyCloudMutation(input, options = {}) {
268
696
  const envelope = input?.envelope || {};
269
- const record = envelope.operation?.record;
270
697
  if (
271
698
  envelope.contract !== STREAM_CONTRACT
699
+ || Number(envelope.schemaVersion) !== 2
272
700
  || envelope.operationKind !== 'code.publish'
273
- || envelope.operation?.kind !== 'code.publish'
274
- || record?.mutationId !== envelope.mutationId
275
701
  ) throw invalid('invalid code project cloud mutation', 409);
702
+ const operation = normalizeOperation(envelope.operation, envelope.resourceId);
276
703
  const database = options.database || openLocalDb();
277
704
  const replica = require('../state/replicas').getReplicaByResourceId(
278
705
  envelope.resourceId,
@@ -283,41 +710,62 @@ function applyCloudMutation(input, options = {}) {
283
710
  }
284
711
  return require('../state/mutations').ingestCloudMutation({
285
712
  ...input,
286
- localResource: localResource(envelope.resourceId),
713
+ localResource: `${LOCAL_RESOURCE_PREFIX}${envelope.resourceId}`,
287
714
  }, {
288
715
  database,
716
+ requireOfficialMaterialization: true,
289
717
  materialize() {
290
- writeSnapshot(
291
- replica.localPath,
292
- applyRecord(
293
- readSnapshot(replica.localPath, record.project),
294
- record,
295
- Number(input.version),
296
- ),
718
+ const next = fold(
719
+ readSnapshot(replica.localPath, envelope.resourceId),
720
+ operation,
721
+ {
722
+ machine: envelope.deviceId,
723
+ mutationId: envelope.mutationId,
724
+ version: Number(input.version),
725
+ },
297
726
  );
727
+ writeSnapshot(replica.localPath, next);
728
+ const project = require('./wall').projectForResource(envelope.resourceId);
729
+ if (project) {
730
+ require('./intent').markPublicationFolded(
731
+ project,
732
+ envelope.mutationId,
733
+ Number(input.version),
734
+ input.committedAt,
735
+ );
736
+ }
298
737
  },
299
738
  event: {
300
- resource: localResource(envelope.resourceId),
739
+ resource: `${LOCAL_RESOURCE_PREFIX}${envelope.resourceId}`,
301
740
  op: 'update',
302
741
  id: replica.localPath,
303
742
  sharedResourceId: envelope.resourceId,
304
743
  resourceVersion: Number(input.version),
305
- value: record,
744
+ value: operation.delta,
306
745
  source: 'code-project:cloud',
307
746
  },
308
747
  });
309
748
  }
310
749
 
311
750
  module.exports = {
751
+ BUNDLE_CONTRACT,
752
+ CARD_CONTRACT,
753
+ CHECKPOINT_CONTRACT,
312
754
  LOCAL_RESOURCE_PREFIX,
755
+ PAYLOAD_CONTRACT,
313
756
  STREAM_CONTRACT,
314
757
  WALL_CONTRACT,
315
- _test: { applyRecord, emptySnapshot, normalizeSnapshot },
758
+ _test: {
759
+ emptySnapshot,
760
+ fold,
761
+ normalizeDelta,
762
+ normalizeOperation,
763
+ normalizeSnapshot,
764
+ },
316
765
  applyCloudMutation,
766
+ emptySnapshot,
317
767
  installSnapshot,
318
768
  publish,
319
769
  readSnapshot,
320
- records,
321
- seal,
322
770
  writeSnapshot,
323
771
  };