amalgm 0.1.239 → 0.1.241

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 (87) hide show
  1. package/lib/shared-realtime-tunnel.js +18 -4
  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/server/routes/state.js +13 -0
  6. package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
  7. package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
  8. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
  9. package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
  10. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
  11. package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
  12. package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
  13. package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
  14. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
  15. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
  16. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
  17. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
  18. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
  19. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
  20. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
  21. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
  22. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
  23. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
  24. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
  25. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
  26. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
  27. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
  28. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
  29. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
  30. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +98 -4
  31. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +156 -8
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
  33. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
  34. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
  35. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
  36. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
  37. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
  38. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
  39. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
  40. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
  41. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
  42. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
  43. package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
  44. package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
  45. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
  46. package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
  47. package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
  48. package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
  49. package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
  50. package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
  51. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
  52. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  53. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  54. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
  55. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
  56. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
  57. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
  58. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
  59. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
  60. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
  61. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
  63. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
  64. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
  65. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
  66. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
  67. package/runtime/scripts/amalgm-mcp/workspace/objects.js +185 -55
  68. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
  69. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
  70. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
  71. package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
  72. package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
  73. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
  74. package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
  75. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
  76. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
  77. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
  78. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
  79. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
  80. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
  81. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
  82. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
  83. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
  84. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
  85. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
  86. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
  87. package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
@@ -545,9 +545,13 @@ function createSharedRealtimeTunnel({
545
545
  void localStateJson('POST', '/state/shared/attachments/fail', {
546
546
  attachmentId: identity.attachmentId,
547
547
  message: error?.message || 'local snapshot materialization failed',
548
- retryable: false,
548
+ // The gateway already authorized and completed this attachment.
549
+ // A local materialization failure is weather (busy disk, concurrent
550
+ // save, restart), never proof that the binding is permanently bad.
551
+ retryable: true,
549
552
  }).catch(() => {});
550
553
  attachmentInflight.delete(frame.request_id);
554
+ scheduleAttachmentDrain(RETRY_MS);
551
555
  });
552
556
  }
553
557
 
@@ -614,9 +618,18 @@ function createSharedRealtimeTunnel({
614
618
  }
615
619
 
616
620
  function handleError(frame) {
617
- if (repoUploadInflight.has(frame.request_id)) {
618
- repoUploadInflight.delete(frame.request_id);
619
- scheduleRepoObjects(RETRY_MS);
621
+ const failedUpload = repoUploadInflight.get(frame.request_id);
622
+ if (failedUpload) {
623
+ void localStateJson('POST', '/state/repo-objects/uploads/fail', {
624
+ resourceId: failedUpload.resourceId,
625
+ sha256: failedUpload.sha256,
626
+ retryable: frame.retryable !== false,
627
+ code: frame.code,
628
+ message: frame.message,
629
+ }).finally(() => {
630
+ repoUploadInflight.delete(frame.request_id);
631
+ scheduleRepoObjects();
632
+ });
620
633
  return;
621
634
  }
622
635
  if (repoDownloadInflight.has(frame.request_id)) {
@@ -697,6 +710,7 @@ function createSharedRealtimeTunnel({
697
710
  void localStateJson('POST', '/state/mutations/fail', {
698
711
  resourceId: identity.resourceId,
699
712
  mutationId: identity.mutationId,
713
+ code: frame.code,
700
714
  message: `${frame.code || 'shared_error'}: ${frame.message || 'shared mutation failed'}`,
701
715
  retryable: frame.retryable !== false,
702
716
  }).finally(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.239",
3
+ "version": "0.1.241",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -599,18 +599,26 @@ function getCookieCloudBootstrap(options = {}) {
599
599
 
600
600
  function installCookieCloudBootstrap(input = {}, options = {}) {
601
601
  const database = options.database || require('../state/db').openLocalDb();
602
- const replica = cookieCloudReplica({ database });
603
- if (!replica || input.resourceId !== replica.resourceId) {
602
+ const replicas = require('../state/replicas');
603
+ const envelope = replicas.validateSnapshotEnvelope(input, {
604
+ contract: COOKIE_CLOUD_CONTRACT,
605
+ schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
606
+ });
607
+ const expectedResourceId = privateCookieResourceId(
608
+ require('../workspace/identity').userId(),
609
+ );
610
+ if (!expectedResourceId || envelope.resourceId !== expectedResourceId) {
604
611
  throw new Error('Private cookie resource does not belong to this runtime user');
605
612
  }
606
- if (input.contract !== COOKIE_CLOUD_CONTRACT || Number(input.schemaVersion) !== COOKIE_JAR_SCHEMA_VERSION) {
607
- throw new Error('Private cookie resource contract changed');
608
- }
609
613
  const bytes = Buffer.from(String(input.snapshotBase64 || ''), 'base64');
610
614
  const checksum = crypto.createHash('sha256').update(bytes).digest('hex');
611
615
  if (checksum !== input.snapshotChecksum) throw new Error('Private cookie snapshot checksum mismatch');
612
616
  const local = loadInternalCookieJar();
613
617
  const remote = normalizeCloudSnapshot(JSON.parse(bytes.toString('utf8')));
618
+ if (remote.version !== envelope.snapshotVersion) {
619
+ throw new Error('Private cookie snapshot version mismatch');
620
+ }
621
+ const replica = cookieCloudReplica({ database });
614
622
  const pending = require('../state/mutations').listPendingCloudMutations(replica.resourceId, { database });
615
623
  const pendingKeys = new Set(pending.map((mutation) => mutation.operation?.key).filter(Boolean));
616
624
  // A base snapshot behind the official head is not the current cloud jar.
@@ -618,28 +626,62 @@ function installCookieCloudBootstrap(input = {}, options = {}) {
618
626
  // as pre-cloud work and create an echo loop on every reconnect. The gateway
619
627
  // normally compacts private resources to head before bootstrap; this guard
620
628
  // makes the runtime fail closed if an older gateway sends a stale base.
621
- const snapshotIsCurrent = Number(input.snapshotVersion) >= Number(input.headVersion);
629
+ const snapshotIsCurrent = envelope.snapshotVersion === envelope.headVersion;
622
630
  const bootstrapOperations = snapshotIsCurrent
623
631
  ? localBootstrapOperations(local, remote, pendingKeys)
624
632
  : [];
625
633
 
634
+ if (!snapshotIsCurrent) {
635
+ // A stale prefix is useful only as an identity check for a prefix this
636
+ // machine has already applied. Advancing to a newer stale prefix without
637
+ // installing its bytes would make the cursor lie; installing it would
638
+ // overwrite local legacy state before a head snapshot can classify it.
639
+ const currentReplica = replicas.refreshReplicaAuthority({
640
+ resourceId: replica.resourceId,
641
+ authorityEpoch: envelope.authorityEpoch,
642
+ headVersion: envelope.headVersion,
643
+ contract: COOKIE_CLOUD_CONTRACT,
644
+ schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
645
+ }, { database });
646
+ return {
647
+ replica: currentReplica,
648
+ jar: readCookieJar(),
649
+ bootstrappedMutations: 0,
650
+ bootstrapDeferred: true,
651
+ };
652
+ }
653
+
654
+ // Legacy local values become durable mutations before the official
655
+ // snapshot can replace the physical jar. If the process dies after the
656
+ // replacement, restart can replay these rows; in-memory bootstrapOperations
657
+ // are never the only copy of user state.
658
+ let currentReplica = replica;
659
+ for (const operation of bootstrapOperations) {
660
+ commitCookieOperation(operation, currentReplica, { database });
661
+ currentReplica = cookieCloudReplica({ database });
662
+ }
663
+ const durablePending = require('../state/mutations')
664
+ .listPendingCloudMutations(replica.resourceId, { database });
665
+
626
666
  persistCloudSnapshot({
627
667
  schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
628
668
  version: remote.version,
629
669
  records: [...remote.records.values()],
630
670
  tombstones: [...remote.tombstones.values()],
631
671
  });
632
- const { advanceReplicaSnapshot, refreshReplicaAuthority } = require('../state/replicas');
633
- advanceReplicaSnapshot({
634
- resourceId: replica.resourceId,
635
- snapshotVersion: Number(input.snapshotVersion) || 0,
636
- snapshotChecksum: checksum,
637
- authorityEpoch: Number(input.authorityEpoch) || 1,
672
+ if (options.testHooks?.crashAfterSnapshotPersist === true) {
673
+ throw new Error('simulated cookie bootstrap crash after snapshot persist');
674
+ }
675
+ replicas.advanceReplicaSnapshot({
676
+ resourceId: replica.resourceId,
677
+ snapshotVersion: envelope.snapshotVersion,
678
+ snapshotChecksum: checksum,
679
+ authorityEpoch: envelope.authorityEpoch,
638
680
  }, { database });
639
- refreshReplicaAuthority({
681
+ replicas.refreshReplicaAuthority({
640
682
  resourceId: replica.resourceId,
641
- authorityEpoch: Number(input.authorityEpoch) || 1,
642
- headVersion: Number(input.headVersion) || 0,
683
+ authorityEpoch: envelope.authorityEpoch,
684
+ headVersion: envelope.headVersion,
643
685
  contract: COOKIE_CLOUD_CONTRACT,
644
686
  schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
645
687
  }, { database });
@@ -647,12 +689,18 @@ function installCookieCloudBootstrap(input = {}, options = {}) {
647
689
  // Snapshot installation never discards local work. Already-journaled edits
648
690
  // replay from their durable rows; pre-cloud legacy entries become ordinary
649
691
  // new mutations through the same commit path.
650
- for (const mutation of pending) materializeCookieOperation(mutation.operation);
651
- let currentReplica = cookieCloudReplica({ database });
652
- for (const operation of bootstrapOperations) {
653
- commitCookieOperation(operation, currentReplica, { database });
654
- currentReplica = cookieCloudReplica({ database });
692
+ for (const mutation of durablePending) {
693
+ materializeCookieOperation(mutation.operation);
694
+ if (mutation.state === 'saving-local' && mutation.materializedAt === null) {
695
+ require('../state/mutations').completeMaterializedMutation(
696
+ mutation.channelId,
697
+ mutation.mutationId,
698
+ cookieEvent(mutation.operation, mutation.mutationId),
699
+ { database },
700
+ );
701
+ }
655
702
  }
703
+ currentReplica = cookieCloudReplica({ database });
656
704
  return {
657
705
  replica: currentReplica,
658
706
  jar: readCookieJar(),
@@ -16,9 +16,9 @@
16
16
  * system-instructions.md global user-authored instructions under AMALGM_DIR
17
17
  *
18
18
  * Direct disk edits (editors, agents with their own file tools) are picked up
19
- * by the per-project fs watcher and the fs REST hooks, which funnel into the
20
- * same reconcile path. Reconcile is idempotent: unchanged revisions emit no
21
- * events.
19
+ * by the per-project fs watcher, a bounded verifier for notifications the OS
20
+ * may drop, and the fs REST hooks. All three funnel into the same reconcile
21
+ * path. Reconcile is idempotent: unchanged revisions emit no events.
22
22
  *
23
23
  * Everything that emits events runs in the amalgm-mcp process — Local Live
24
24
  * only streams events appended in-process. chat-core (chat-server process)
@@ -56,11 +56,13 @@ const {
56
56
 
57
57
  const RESOURCE = 'project_context';
58
58
  const WATCH_DEBOUNCE_MS = 200;
59
+ const WATCH_VERIFY_MS = 5_000;
59
60
  // The system scope carries user-authored instructions that apply to every
60
61
  // session on this computer. It has no change log or project state.
61
62
  const SYSTEM_CONTEXT_ID = 'system';
62
63
 
63
- const watchers = new Map(); // projectId -> { watcher, timer, projectPath }
64
+ const watchers = new Map(); // projectId -> { watcher, timer, project, projectPath }
65
+ let watchVerificationTimer = null;
64
66
 
65
67
  function systemProject() {
66
68
  return { id: SYSTEM_CONTEXT_ID, path: path.resolve(AMALGM_DIR), name: 'Amalgm System' };
@@ -683,12 +685,50 @@ function closeWatcher(projectId) {
683
685
  const entry = watchers.get(projectId);
684
686
  if (!entry) return;
685
687
  if (entry.timer) clearTimeout(entry.timer);
686
- try {
687
- entry.watcher.close();
688
- } catch {
689
- // Already closed.
688
+ if (entry.watcher) {
689
+ try {
690
+ entry.watcher.close();
691
+ } catch {
692
+ // Already closed.
693
+ }
690
694
  }
691
695
  watchers.delete(projectId);
696
+ if (watchers.size === 0 && watchVerificationTimer) {
697
+ clearInterval(watchVerificationTimer);
698
+ watchVerificationTimer = null;
699
+ }
700
+ }
701
+
702
+ /**
703
+ * `fs.watch` is a latency hint, not durable evidence: kernels may coalesce or
704
+ * drop notifications. This bounded pass owns that wait and makes every
705
+ * watched context converge even when no callback arrives.
706
+ */
707
+ function verifyProjectContextWatchers() {
708
+ for (const entry of watchers.values()) {
709
+ try {
710
+ reconcileProjectContext(entry.project, { source: 'project-context:verify' });
711
+ if (!entry.watcher) {
712
+ entry.watcher = openProjectContextWatcher(entry.project);
713
+ }
714
+ } catch (error) {
715
+ const message = error instanceof Error ? error.message : String(error);
716
+ console.warn(
717
+ `[ProjectContext] Bounded verify failed for ${entry.projectPath}: ${message}`,
718
+ );
719
+ }
720
+ }
721
+ }
722
+
723
+ function ensureWatchVerification() {
724
+ if (watchVerificationTimer || watchers.size === 0) return;
725
+ watchVerificationTimer = setInterval(
726
+ verifyProjectContextWatchers,
727
+ WATCH_VERIFY_MS,
728
+ );
729
+ if (typeof watchVerificationTimer.unref === 'function') {
730
+ watchVerificationTimer.unref();
731
+ }
692
732
  }
693
733
 
694
734
  /**
@@ -697,13 +737,7 @@ function closeWatcher(projectId) {
697
737
  * harmless: by the time the debounce fires, disk matches the row and no event
698
738
  * is emitted.
699
739
  */
700
- function watchProjectContext(input) {
701
- const project = projectFromInput(input);
702
- if (!project?.id || !project?.path) return;
703
- const existing = watchers.get(project.id);
704
- if (existing?.projectPath === project.path) return;
705
- if (existing) closeWatcher(project.id);
706
-
740
+ function openProjectContextWatcher(project) {
707
741
  const root = contextPathsFor(project).root;
708
742
  let watcher;
709
743
  try {
@@ -723,12 +757,46 @@ function watchProjectContext(input) {
723
757
  if (typeof entry.timer.unref === 'function') entry.timer.unref();
724
758
  });
725
759
  } catch {
726
- // Context dir missing or unwatchable — best effort; REST hooks still cover writes.
727
- return;
760
+ // The bounded verifier remains the durable fallback and retries this hint.
761
+ return null;
728
762
  }
729
- watcher.on('error', () => closeWatcher(project.id));
763
+ watcher.on('error', () => {
764
+ const entry = watchers.get(project.id);
765
+ if (!entry || entry.watcher !== watcher) return;
766
+ try {
767
+ watcher.close();
768
+ } catch {
769
+ // Already closed.
770
+ }
771
+ entry.watcher = null;
772
+ });
730
773
  if (typeof watcher.unref === 'function') watcher.unref();
731
- watchers.set(project.id, { watcher, timer: null, projectPath: project.path });
774
+ return watcher;
775
+ }
776
+
777
+ function watchProjectContext(input) {
778
+ const project = projectFromInput(input);
779
+ if (!project?.id || !project?.path) return;
780
+ const existing = watchers.get(project.id);
781
+ if (existing?.projectPath === project.path) {
782
+ existing.project = project;
783
+ if (!existing.watcher) {
784
+ existing.watcher = openProjectContextWatcher(project);
785
+ }
786
+ ensureWatchVerification();
787
+ return;
788
+ }
789
+ if (existing) closeWatcher(project.id);
790
+
791
+ const entry = {
792
+ watcher: null,
793
+ timer: null,
794
+ project,
795
+ projectPath: project.path,
796
+ };
797
+ watchers.set(project.id, entry);
798
+ entry.watcher = openProjectContextWatcher(project);
799
+ ensureWatchVerification();
732
800
  }
733
801
 
734
802
  /** fs REST hook: reconcile when a context doc is touched outside the store. */
@@ -59,6 +59,19 @@ async function handleStateRoutes(ctx) {
59
59
  ));
60
60
  return true;
61
61
  }
62
+ if (ctx.pathname === '/state/repo-objects/uploads/fail') {
63
+ const body = await ctx.readJsonBody();
64
+ ctx.sendJson(200, require('../../workspace/objects').failUpload(
65
+ body?.resourceId,
66
+ body?.sha256,
67
+ {
68
+ retryable: body?.retryable,
69
+ code: body?.code,
70
+ message: body?.message,
71
+ },
72
+ ));
73
+ return true;
74
+ }
62
75
  if (ctx.pathname === '/state/repo-objects/downloads/claim') {
63
76
  ctx.sendJson(200, { download: require('../../workspace/objects').claimDownload() });
64
77
  return true;
@@ -4,7 +4,7 @@ const crypto = require('crypto');
4
4
 
5
5
  const { cachedStatement, openLocalDb } = require('./db');
6
6
  const { isSharedResourceId } = require('./cloud-resource-id');
7
- const { appendStateEvent } = require('./events');
7
+ const { appendStateEvent, insertStateEvent } = require('./events');
8
8
 
9
9
  const ATTACHMENT_ID = /^[A-Za-z0-9_-]{16,160}$/;
10
10
 
@@ -30,6 +30,10 @@ function normalizeRow(row) {
30
30
  attemptCount: Number(row.attempt_count),
31
31
  nextAttemptAt: row.next_attempt_at || null,
32
32
  lastError: row.last_error || null,
33
+ // Legacy requests predate the fence. Their target was required to be
34
+ // empty, so zero is the only conservative boundary: every surviving or
35
+ // newly captured local mutation remains overlay.
36
+ journalCursor: row.journal_cursor == null ? 0 : Number(row.journal_cursor),
33
37
  createdAt: row.created_at,
34
38
  updatedAt: row.updated_at,
35
39
  };
@@ -54,12 +58,16 @@ function enqueueAttachment(input, options = {}) {
54
58
  return { attachment, duplicate: true };
55
59
  }
56
60
  const now = new Date().toISOString();
61
+ const journalCursor = Number(
62
+ cachedStatement(database, 'SELECT COALESCE(MAX(journal_id), 0) AS cursor FROM mutation_journal')
63
+ .get().cursor,
64
+ );
57
65
  try {
58
66
  cachedStatement(database, `
59
67
  INSERT INTO shared_resource_attachments (
60
- attachment_id, resource_id, local_path, created_at, updated_at
61
- ) VALUES (?, ?, ?, ?, ?)
62
- `).run(attachmentId, resourceId, localPath, now, now);
68
+ attachment_id, resource_id, local_path, journal_cursor, created_at, updated_at
69
+ ) VALUES (?, ?, ?, ?, ?, ?)
70
+ `).run(attachmentId, resourceId, localPath, journalCursor, now, now);
63
71
  } catch (error) {
64
72
  if (String(error?.code || '').includes('CONSTRAINT')) {
65
73
  throw invalid('that resource or local path already has an attachment request', 409);
@@ -70,14 +78,22 @@ function enqueueAttachment(input, options = {}) {
70
78
  cachedStatement(database, 'SELECT * FROM shared_resource_attachments WHERE attachment_id = ?')
71
79
  .get(attachmentId),
72
80
  );
73
- appendStateEvent({
81
+ const eventInput = {
74
82
  resource: 'shared:attachments',
75
83
  op: 'update',
76
84
  id: attachmentId,
77
85
  sharedResourceId: resourceId,
78
86
  value: { state: 'pending' },
79
87
  source: 'shared:attachment',
80
- });
88
+ };
89
+ if (options.deferEvent === true) {
90
+ return {
91
+ attachment,
92
+ duplicate: false,
93
+ deferredEvent: insertStateEvent(database, eventInput),
94
+ };
95
+ }
96
+ appendStateEvent(eventInput);
81
97
  return { attachment, duplicate: false };
82
98
  }
83
99
 
@@ -129,13 +145,16 @@ function acknowledgeAttachment(attachmentIdInput, payload, options = {}) {
129
145
 
130
146
  const installed = payload?.contract === 'workspace-tree@1'
131
147
  ? require('../workspace/tree-store').installCloudTreeSnapshot(attachment.localPath, payload, { database })
132
- : payload?.contract === 'code-project-stream@1'
148
+ : payload?.contract === 'code-project-stream@2'
133
149
  ? require('../workspace/project-stream').installSnapshot(
134
150
  attachment.localPath,
135
151
  payload,
136
152
  { database },
137
153
  )
138
- : require('./docs').installCloudDocSnapshot(attachment.localPath, payload);
154
+ : require('./docs').installCloudDocSnapshot(attachment.localPath, {
155
+ ...payload,
156
+ attachmentJournalCursor: attachment.journalCursor,
157
+ });
139
158
  const now = new Date().toISOString();
140
159
  cachedStatement(database, `
141
160
  UPDATE shared_resource_attachments
@@ -200,21 +219,30 @@ function requestRehydration(input, options = {}) {
200
219
  let attachmentId;
201
220
  if (existing) {
202
221
  attachmentId = existing.attachment_id;
222
+ const journalCursor = Number(
223
+ cachedStatement(database, 'SELECT COALESCE(MAX(journal_id), 0) AS cursor FROM mutation_journal')
224
+ .get().cursor,
225
+ );
203
226
  cachedStatement(database, `
204
227
  UPDATE shared_resource_attachments
205
228
  SET attachment_state = 'pending',
206
229
  next_attempt_at = NULL,
207
230
  last_error = NULL,
231
+ journal_cursor = ?,
208
232
  updated_at = ?
209
233
  WHERE attachment_id = ?
210
- `).run(now, attachmentId);
234
+ `).run(journalCursor, now, attachmentId);
211
235
  } else {
212
236
  attachmentId = `att_${crypto.randomBytes(16).toString('base64url')}`;
237
+ const journalCursor = Number(
238
+ cachedStatement(database, 'SELECT COALESCE(MAX(journal_id), 0) AS cursor FROM mutation_journal')
239
+ .get().cursor,
240
+ );
213
241
  cachedStatement(database, `
214
242
  INSERT INTO shared_resource_attachments (
215
- attachment_id, resource_id, local_path, created_at, updated_at
216
- ) VALUES (?, ?, ?, ?, ?)
217
- `).run(attachmentId, resourceId, replica.localPath, now, now);
243
+ attachment_id, resource_id, local_path, journal_cursor, created_at, updated_at
244
+ ) VALUES (?, ?, ?, ?, ?, ?)
245
+ `).run(attachmentId, resourceId, replica.localPath, journalCursor, now, now);
218
246
  }
219
247
  const attachment = normalizeRow(
220
248
  cachedStatement(database, 'SELECT * FROM shared_resource_attachments WHERE attachment_id = ?')