@agent-native/core 0.182.1 → 0.182.2-nightly-20260917225811

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 (119) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/app/root.tsx +59 -10
  3. package/corpus/templates/clips/changelog/2026-09-17-desktop-popover-closes-when-clicking-away.md +6 -0
  4. package/corpus/templates/clips/changelog/2026-09-17-self-hosted-extension-sign-in.md +6 -0
  5. package/corpus/templates/clips/chrome-extension/src/background.ts +45 -25
  6. package/corpus/templates/clips/chrome-extension/src/content-script.ts +43 -0
  7. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +55 -6
  8. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +107 -19
  9. package/corpus/templates/design/.generated/bridge/hit-test.generated.ts +1 -0
  10. package/corpus/templates/design/actions/add-localhost-screens.ts +54 -22
  11. package/corpus/templates/design/actions/apply-component-prop-edit.ts +174 -14
  12. package/corpus/templates/design/actions/apply-visual-edit.ts +3 -1
  13. package/corpus/templates/design/actions/create-component.ts +173 -12
  14. package/corpus/templates/design/actions/create-design-from-template.ts +50 -35
  15. package/corpus/templates/design/actions/create-design-version.ts +21 -0
  16. package/corpus/templates/design/actions/create-file.ts +35 -33
  17. package/corpus/templates/design/actions/delete-design.ts +29 -28
  18. package/corpus/templates/design/actions/delete-file.ts +154 -23
  19. package/corpus/templates/design/actions/generate-design.ts +28 -16
  20. package/corpus/templates/design/actions/get-component-details.ts +272 -33
  21. package/corpus/templates/design/actions/get-design-version.ts +9 -1
  22. package/corpus/templates/design/actions/go-to-main-component.ts +37 -35
  23. package/corpus/templates/design/actions/index-components.ts +241 -126
  24. package/corpus/templates/design/actions/migrate-board-objects-to-file.ts +12 -9
  25. package/corpus/templates/design/actions/present-design-variants.ts +68 -72
  26. package/corpus/templates/design/actions/rename-component.ts +294 -0
  27. package/corpus/templates/design/actions/rename-screen.ts +10 -10
  28. package/corpus/templates/design/actions/update-file.ts +327 -330
  29. package/corpus/templates/design/actions/update-screen-source.ts +2 -43
  30. package/corpus/templates/design/app/components/design/EditPanel.tsx +17 -1
  31. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +228 -24
  32. package/corpus/templates/design/app/components/design/ResponsiveInteractBar.tsx +17 -1
  33. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +151 -32
  34. package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +4 -0
  35. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +1 -1
  36. package/corpus/templates/design/app/components/design/code-workbench/CodeWorkbench.tsx +8 -1
  37. package/corpus/templates/design/app/components/design/code-workbench/explorer/FileTree.tsx +55 -33
  38. package/corpus/templates/design/app/components/design/code-workbench/workspace/create-providers.ts +2 -0
  39. package/corpus/templates/design/app/components/design/code-workbench/workspace/inline-provider.ts +4 -2
  40. package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +273 -60
  41. package/corpus/templates/design/app/components/design/edit-panel/element-classification.ts +9 -2
  42. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +57 -9
  43. package/corpus/templates/design/app/components/design/multi-screen/primitive-drop-target.ts +13 -0
  44. package/corpus/templates/design/app/components/design/multi-screen/types.ts +14 -0
  45. package/corpus/templates/design/app/components/design/types.ts +9 -1
  46. package/corpus/templates/design/app/hooks/use-design-versions.ts +3 -2
  47. package/corpus/templates/design/app/i18n-breakpoints.ts +0 -110
  48. package/corpus/templates/design/app/i18n-data.ts +0 -10
  49. package/corpus/templates/design/app/pages/DesignEditor.tsx +669 -161
  50. package/corpus/templates/design/app/pages/design-editor/clone-and-pen-edit.ts +20 -0
  51. package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +2 -0
  52. package/corpus/templates/design/app/pages/design-editor/commands/apply-design-editor-command.ts +21 -3
  53. package/corpus/templates/design/app/pages/design-editor/commands/apply-file-content-update.ts +2 -0
  54. package/corpus/templates/design/app/pages/design-editor/commands/create-component.ts +44 -3
  55. package/corpus/templates/design/app/pages/design-editor/commands/delete-files.ts +69 -47
  56. package/corpus/templates/design/app/pages/design-editor/commands/duplicate-screen.ts +157 -52
  57. package/corpus/templates/design/app/pages/design-editor/commands/duplicate-selection.ts +76 -1
  58. package/corpus/templates/design/app/pages/design-editor/commands/redo.ts +152 -44
  59. package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +131 -2
  60. package/corpus/templates/design/app/pages/design-editor/commands/screen-visual-duplicate-change.ts +5 -1
  61. package/corpus/templates/design/app/pages/design-editor/commands/undo.ts +26 -1
  62. package/corpus/templates/design/app/pages/design-editor/commands/visual-duplicate-change.ts +1 -0
  63. package/corpus/templates/design/app/pages/design-editor/editor-helpers.ts +58 -8
  64. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +63 -2
  65. package/corpus/templates/design/app/pages/design-editor/history.ts +3 -0
  66. package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +74 -22
  67. package/corpus/templates/design/app/pages/design-editor/source-publication.ts +14 -2
  68. package/corpus/templates/design/changelog/2026-09-16-design-components-retain-linked-identities-when-edited-and-d.md +6 -0
  69. package/corpus/templates/design/changelog/2026-09-16-fix-design-nested-layer-dragging-and-screen-duplication-pari.md +6 -0
  70. package/corpus/templates/design/changelog/2026-09-17-design-drag-and-drop-keeps-duplicates-cross-screen-moves-and.md +6 -0
  71. package/corpus/templates/design/changelog/2026-09-17-design-preserves-reusable-component-identity-and-props-acros.md +6 -0
  72. package/corpus/templates/design/changelog/2026-09-17-open-vector-paths-no-longer-show-fills-and-interact-dimensio.md +6 -0
  73. package/corpus/templates/design/changelog/2026-09-17-screen-deletion-is-immediate-and-undoable-with-history-that-.md +6 -0
  74. package/corpus/templates/design/e2e/fixtures/design-component-react/index.html +98 -0
  75. package/corpus/templates/design/e2e/fixtures/design-component-react/inline-component.html +65 -0
  76. package/corpus/templates/design/e2e/helpers.ts +6 -1
  77. package/corpus/templates/design/server/lib/design-data-mutation.ts +9 -5
  78. package/corpus/templates/design/server/lib/design-snapshot.ts +28 -16
  79. package/corpus/templates/design/server/lib/design-versions.ts +208 -130
  80. package/corpus/templates/design/server/lib/fusion-screens.ts +46 -20
  81. package/corpus/templates/design/server/lib/import-design-files.ts +5 -1
  82. package/corpus/templates/design/server/lib/localhost-connection.ts +54 -0
  83. package/corpus/templates/design/server/source-workspace.ts +412 -356
  84. package/corpus/templates/design/shared/component-library.ts +36 -6
  85. package/corpus/templates/design/shared/component-model.ts +12 -2
  86. package/corpus/templates/design/shared/local-jsx-visual-edit.ts +231 -5
  87. package/corpus/templates/design/shared/source-mode.ts +22 -0
  88. package/dist/automation/index.d.ts +1 -1
  89. package/dist/client/AssistantChat.d.ts +4 -1
  90. package/dist/client/AssistantChat.js +26 -3
  91. package/dist/client/chat/agentkit-protocol.js +148 -34
  92. package/dist/client/chat/message-components.js +7 -5
  93. package/dist/client/chat/tool-call-display.js +4 -3
  94. package/dist/client/conversation/AgentConversation.js +4 -2
  95. package/dist/client/tool-display.d.ts +9 -0
  96. package/dist/client/tool-display.js +15 -0
  97. package/dist/collab/awareness.d.ts +2 -2
  98. package/dist/collab/routes.d.ts +1 -1
  99. package/dist/localization/core-messages/ar-SA.js +2 -0
  100. package/dist/localization/core-messages/de-DE.js +2 -0
  101. package/dist/localization/core-messages/en-US.d.ts +2 -0
  102. package/dist/localization/core-messages/en-US.js +2 -0
  103. package/dist/localization/core-messages/es-ES.js +2 -0
  104. package/dist/localization/core-messages/fr-FR.js +2 -0
  105. package/dist/localization/core-messages/hi-IN.js +2 -0
  106. package/dist/localization/core-messages/ja-JP.js +2 -0
  107. package/dist/localization/core-messages/ko-KR.js +2 -0
  108. package/dist/localization/core-messages/pt-BR.js +2 -0
  109. package/dist/localization/core-messages/zh-CN.js +2 -0
  110. package/dist/localization/core-messages/zh-TW.js +2 -0
  111. package/dist/localization/core-messages.d.ts +2 -0
  112. package/dist/notifications/routes.d.ts +3 -3
  113. package/dist/observability/routes.d.ts +3 -3
  114. package/dist/resources/handlers.d.ts +1 -1
  115. package/dist/secrets/routes.d.ts +3 -3
  116. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  117. package/dist/server/core-routes-plugin.d.ts +1 -1
  118. package/package.json +3 -3
  119. package/corpus/templates/design/app/components/design/editor/PendingScreenDeletionDialog.tsx +0 -66
@@ -6,12 +6,15 @@ import {
6
6
  seedFromText,
7
7
  } from "@agent-native/core/collab";
8
8
  import { accessFilter, assertAccess } from "@agent-native/core/sharing";
9
- import { and, eq, isNull, sql } from "drizzle-orm";
9
+ import { and, eq, isNull } from "drizzle-orm";
10
10
  import { z } from "zod";
11
11
 
12
12
  import { getDb, schema } from "../server/db/index.js";
13
13
  import { snapshotDesignBeforeAgentEdit } from "../server/lib/design-versions.js";
14
14
  import {
15
+ affectedRowCount,
16
+ lockDesignFilesTable,
17
+ withDesignSourceMutationTransaction,
15
18
  withSourceFileWriteLock,
16
19
  writeInlineSourceFile,
17
20
  } from "../server/source-workspace.js";
@@ -43,17 +46,6 @@ function fileNotFound(id: string): Error & { statusCode?: number } {
43
46
  return err;
44
47
  }
45
48
 
46
- function rowsAffected(result: unknown): number | undefined {
47
- const candidate = result as {
48
- rowsAffected?: unknown;
49
- rowCount?: unknown;
50
- changes?: unknown;
51
- } | null;
52
- const value =
53
- candidate?.rowsAffected ?? candidate?.rowCount ?? candidate?.changes;
54
- return typeof value === "number" ? value : undefined;
55
- }
56
-
57
49
  /**
58
50
  * Returns `{ id, updated: true }` on success, matching the pre-existing
59
51
  * contract — callers that only check `result.updated === true` see no
@@ -310,224 +302,222 @@ export default defineAction({
310
302
  let exactOperationAlreadyPersisted = false;
311
303
  let persistedVersionHash: string | undefined;
312
304
 
313
- await withSourceFileWriteLock(id, async () => {
314
- for (let attempt = 0; attempt < 4; attempt += 1) {
315
- skippedStaleMirror = false;
316
- skippedStaleOperation = false;
317
- exactOperationAlreadyPersisted = false;
318
- const [persistedFile] = await db
319
- .select({
320
- content: schema.designFiles.content,
321
- fileType: schema.designFiles.fileType,
322
- contentOperationSource: schema.designFiles.contentOperationSource,
323
- contentOperationRevision:
324
- schema.designFiles.contentOperationRevision,
325
- contentOperationResultHash:
326
- schema.designFiles.contentOperationResultHash,
327
- })
328
- .from(schema.designFiles)
329
- .where(eq(schema.designFiles.id, id))
330
- .limit(1);
331
- if (!persistedFile) {
332
- // Delete-race: the row passed the access check but is gone now.
333
- // Same 404 as the outer guard, not a bare 500 the outbox retries.
334
- throw fileNotFound(id);
335
- }
336
-
337
- const persistedContentHash = sourceContentHash(persistedFile.content);
338
- persistedVersionHash = persistedContentHash;
339
- const collabExists =
340
- content !== undefined ? await hasCollabState(id) : false;
341
- const liveContent =
342
- content !== undefined && collabExists
343
- ? await getText(id, "content")
344
- : persistedFile.content;
345
- if (content !== undefined) {
346
- assertDesignHtmlEditIntegrity({
347
- previousContent: liveContent,
348
- nextContent: content,
349
- fileType:
350
- fileType ?? persistedFile.fileType ?? file.fileType ?? "html",
351
- });
352
- }
353
- // Applicability belongs to the guard, which cheaply short-circuits
354
- // when neither side carries a lock. Gating on the REQUEST's fileType
355
- // let a content-only save add a lock the live document never had.
356
- if (content !== undefined && context?.caller !== "frontend") {
357
- assertLockedLayersPreserved(liveContent, content);
358
- }
359
- const hasVersionedContentOperation =
360
- content !== undefined &&
361
- operationSource !== undefined &&
362
- operationRevision !== undefined;
363
- const requestedOperationRevision = operationRevision ?? null;
364
- const sameOperationSource =
365
- hasVersionedContentOperation &&
366
- persistedFile.contentOperationSource === operationSource &&
367
- typeof persistedFile.contentOperationRevision === "number";
305
+ await withSourceFileWriteLock(id, () =>
306
+ withDesignSourceMutationTransaction(file.designId, async (tx) => {
307
+ await lockDesignFilesTable(tx);
308
+ for (let attempt = 0; attempt < 4; attempt += 1) {
309
+ skippedStaleMirror = false;
310
+ skippedStaleOperation = false;
311
+ exactOperationAlreadyPersisted = false;
312
+ const [persistedFile] = await tx
313
+ .select({
314
+ content: schema.designFiles.content,
315
+ fileType: schema.designFiles.fileType,
316
+ contentOperationSource: schema.designFiles.contentOperationSource,
317
+ contentOperationRevision:
318
+ schema.designFiles.contentOperationRevision,
319
+ contentOperationResultHash:
320
+ schema.designFiles.contentOperationResultHash,
321
+ })
322
+ .from(schema.designFiles)
323
+ .where(eq(schema.designFiles.id, id))
324
+ .limit(1);
325
+ if (!persistedFile) {
326
+ // Delete-race: the row passed the access check but is gone now.
327
+ // Same 404 as the outer guard, not a bare 500 the outbox retries.
328
+ throw fileNotFound(id);
329
+ }
368
330
 
369
- // A pagehide keepalive can overtake the older normal fetch for this
370
- // same tab. Once the newer revision has committed, the late request is
371
- // an idempotent no-op regardless of its stale expectedVersionHash. Do
372
- // this before the content hash guard so request arrival order cannot
373
- // turn a correct latest save into a conflict or overwrite.
374
- if (
375
- sameOperationSource &&
376
- requestedOperationRevision !== null &&
377
- requestedOperationRevision <= persistedFile.contentOperationRevision!
378
- ) {
379
- skippedStaleOperation = true;
380
- // The SQL CAS may have committed before the separate collab apply
381
- // failed. A retry of that exact operation must be allowed to finish
382
- // convergence; an older revision must remain a strict no-op. Require
383
- // both persisted hashes to prove the requested content is exactly
384
- // what this operation committed before re-running any side effect.
385
- exactOperationAlreadyPersisted =
386
- requestedOperationRevision ===
387
- persistedFile.contentOperationRevision &&
331
+ const persistedContentHash = sourceContentHash(persistedFile.content);
332
+ persistedVersionHash = persistedContentHash;
333
+ const collabExists =
334
+ content !== undefined ? await hasCollabState(id) : false;
335
+ const liveContent =
336
+ content !== undefined && collabExists
337
+ ? await getText(id, "content")
338
+ : persistedFile.content;
339
+ if (content !== undefined) {
340
+ assertDesignHtmlEditIntegrity({
341
+ previousContent: liveContent,
342
+ nextContent: content,
343
+ fileType:
344
+ fileType ?? persistedFile.fileType ?? file.fileType ?? "html",
345
+ });
346
+ }
347
+ // Applicability belongs to the guard, which cheaply short-circuits
348
+ // when neither side carries a lock. Gating on the REQUEST's fileType
349
+ // let a content-only save add a lock the live document never had.
350
+ if (content !== undefined && context?.caller !== "frontend") {
351
+ assertLockedLayersPreserved(liveContent, content);
352
+ }
353
+ const hasVersionedContentOperation =
388
354
  content !== undefined &&
389
- persistedContentHash === sourceContentHash(content) &&
390
- persistedFile.contentOperationResultHash === persistedContentHash;
391
- }
355
+ operationSource !== undefined &&
356
+ operationRevision !== undefined;
357
+ const requestedOperationRevision = operationRevision ?? null;
358
+ const sameOperationSource =
359
+ hasVersionedContentOperation &&
360
+ persistedFile.contentOperationSource === operationSource &&
361
+ typeof persistedFile.contentOperationRevision === "number";
392
362
 
393
- // SQL-mirror-only skip path: when the caller explicitly opted OUT of
394
- // collab sync (syncCollab: false) and supplied an expectedVersionHash
395
- // that no longer matches the LIVE collab text, and a live collab doc
396
- // actually exists for this file, the caller's `content` was computed
397
- // from a base that a live editor has since moved past. Overwriting the
398
- // SQL mirror column with that stale content here would silently regress
399
- // it out from under the live document (which stays the source of
400
- // truth) the next time it's read back out of SQL. Skip the content
401
- // write instead of throwing: filename/fileType updates in the same call
402
- // still proceed, and the caller gets `skippedStaleMirror: true` back
403
- // instead of a thrown error, because they explicitly said they weren't
404
- // trying to sync into collab in the first place. Every other
405
- // expectedVersionHash combination (syncCollab true/default, or no live
406
- // collab state, or a matching hash, or no hash at all) is UNCHANGED.
407
- //
408
- // Own-edit false-positive fix: a single client's own edit reaches the
409
- // live collab doc via TWO independent, unordered paths — the Yjs
410
- // update (~80ms client debounce, applied to the server's in-memory doc
411
- // as soon as its POST lands) and this guarded update-file call (~400ms
412
- // client debounce). The Yjs path usually wins the race, so by the time
413
- // this call's hash check runs, `liveContent` often already equals the
414
- // very `content` this call is trying to write — that is NOT a
415
- // divergent concurrent edit, it's the same edit having arrived early
416
- // by a different transport. Comparing hashes first would reject that
417
- // as "stale" and permanently skip the SQL mirror write (there is no
418
- // background job that later reconciles design_files.content from the
419
- // live collab doc — see hasCollabState below), silently losing writes
420
- // on every edit after the first in a session. Check content equality
421
- // BEFORE the hash comparison so this exact-match case always proceeds
422
- // as a normal write instead of hitting either the skip or throw path.
423
- let skipContentWrite = skippedStaleOperation;
424
- if (
425
- !skippedStaleOperation &&
426
- expectedVersionHash !== undefined &&
427
- content !== undefined
428
- ) {
363
+ // A pagehide keepalive can overtake the older normal fetch for this
364
+ // same tab. Once the newer revision has committed, the late request is
365
+ // an idempotent no-op regardless of its stale expectedVersionHash. Do
366
+ // this before the content hash guard so request arrival order cannot
367
+ // turn a correct latest save into a conflict or overwrite.
429
368
  if (
430
- liveContent !== content &&
431
- sourceContentHash(liveContent) !== expectedVersionHash
369
+ sameOperationSource &&
370
+ requestedOperationRevision !== null &&
371
+ requestedOperationRevision <=
372
+ persistedFile.contentOperationRevision!
432
373
  ) {
433
- if (syncCollab === false && collabExists) {
434
- // A delayed client transport does not invalidate the SQL lineage.
435
- // Preserve the mirror CAS, but keep CRDT authorship with the client:
436
- // its original deltas can still arrive after this HTTP save.
437
- if (persistedContentHash !== expectedVersionHash) {
438
- skipContentWrite = true;
439
- skippedStaleMirror = true;
440
- }
441
- } else {
442
- logSaveConflictDebug("content-conflict", {
443
- id,
444
- caller: context?.caller,
445
- syncCollab,
446
- operationRevision: operationRevision ?? null,
447
- expectedVersionHash,
448
- sentContentHash: sourceContentHash(content),
449
- liveContentHash: sourceContentHash(liveContent),
450
- persistedMirrorHash: persistedContentHash,
451
- });
452
- // 409 (statusCode), not a bare 500: an expected optimistic-
453
- // concurrency outcome the framework returns verbatim and the client
454
- // rebases from, instead of a Sentry-captured fault + retry storm.
455
- const conflict = new Error(
456
- "File changed since it was read. Re-read the file and retry.",
457
- ) as Error & { statusCode?: number };
458
- conflict.statusCode = 409;
459
- throw conflict;
460
- }
374
+ skippedStaleOperation = true;
375
+ // The SQL CAS may have committed before the separate collab apply
376
+ // failed. A retry of that exact operation must be allowed to finish
377
+ // convergence; an older revision must remain a strict no-op. Require
378
+ // both persisted hashes to prove the requested content is exactly
379
+ // what this operation committed before re-running any side effect.
380
+ exactOperationAlreadyPersisted =
381
+ requestedOperationRevision ===
382
+ persistedFile.contentOperationRevision &&
383
+ content !== undefined &&
384
+ persistedContentHash === sourceContentHash(content) &&
385
+ persistedFile.contentOperationResultHash === persistedContentHash;
461
386
  }
462
- }
463
387
 
464
- const updates: Record<string, unknown> = { updatedAt: now };
465
- if (content !== undefined && !skipContentWrite) {
466
- updates.content = content;
467
- persistedVersionHash = sourceContentHash(content);
388
+ // SQL-mirror-only skip path: when the caller explicitly opted OUT of
389
+ // collab sync (syncCollab: false) and supplied an expectedVersionHash
390
+ // that no longer matches the LIVE collab text, and a live collab doc
391
+ // actually exists for this file, the caller's `content` was computed
392
+ // from a base that a live editor has since moved past. Overwriting the
393
+ // SQL mirror column with that stale content here would silently regress
394
+ // it out from under the live document (which stays the source of
395
+ // truth) the next time it's read back out of SQL. Skip the content
396
+ // write instead of throwing: filename/fileType updates in the same call
397
+ // still proceed, and the caller gets `skippedStaleMirror: true` back
398
+ // instead of a thrown error, because they explicitly said they weren't
399
+ // trying to sync into collab in the first place. Every other
400
+ // expectedVersionHash combination (syncCollab true/default, or no live
401
+ // collab state, or a matching hash, or no hash at all) is UNCHANGED.
402
+ //
403
+ // Own-edit false-positive fix: a single client's own edit reaches the
404
+ // live collab doc via TWO independent, unordered paths — the Yjs
405
+ // update (~80ms client debounce, applied to the server's in-memory doc
406
+ // as soon as its POST lands) and this guarded update-file call (~400ms
407
+ // client debounce). The Yjs path usually wins the race, so by the time
408
+ // this call's hash check runs, `liveContent` often already equals the
409
+ // very `content` this call is trying to write — that is NOT a
410
+ // divergent concurrent edit, it's the same edit having arrived early
411
+ // by a different transport. Comparing hashes first would reject that
412
+ // as "stale" and permanently skip the SQL mirror write (there is no
413
+ // background job that later reconciles design_files.content from the
414
+ // live collab doc — see hasCollabState below), silently losing writes
415
+ // on every edit after the first in a session. Check content equality
416
+ // BEFORE the hash comparison so this exact-match case always proceeds
417
+ // as a normal write instead of hitting either the skip or throw path.
418
+ let skipContentWrite = skippedStaleOperation;
468
419
  if (
469
- operationSource !== undefined &&
470
- operationRevision !== undefined
420
+ !skippedStaleOperation &&
421
+ expectedVersionHash !== undefined &&
422
+ content !== undefined
471
423
  ) {
472
- updates.contentOperationSource = operationSource;
473
- updates.contentOperationRevision = operationRevision;
474
- updates.contentOperationResultHash = persistedVersionHash;
475
- } else {
476
- // An unversioned writer starts a different lineage. Clearing the
477
- // browser operation marker prevents a later request from treating
478
- // stale transport metadata as proof that no writer intervened.
479
- updates.contentOperationSource = null;
480
- updates.contentOperationRevision = null;
481
- updates.contentOperationResultHash = null;
424
+ if (
425
+ liveContent !== content &&
426
+ sourceContentHash(liveContent) !== expectedVersionHash
427
+ ) {
428
+ if (syncCollab === false && collabExists) {
429
+ // A delayed client transport does not invalidate the SQL lineage.
430
+ // Preserve the mirror CAS, but keep CRDT authorship with the client:
431
+ // its original deltas can still arrive after this HTTP save.
432
+ if (persistedContentHash !== expectedVersionHash) {
433
+ skipContentWrite = true;
434
+ skippedStaleMirror = true;
435
+ }
436
+ } else {
437
+ logSaveConflictDebug("content-conflict", {
438
+ id,
439
+ caller: context?.caller,
440
+ syncCollab,
441
+ operationRevision: operationRevision ?? null,
442
+ expectedVersionHash,
443
+ sentContentHash: sourceContentHash(content),
444
+ liveContentHash: sourceContentHash(liveContent),
445
+ persistedMirrorHash: persistedContentHash,
446
+ });
447
+ // 409 (statusCode), not a bare 500: an expected optimistic-
448
+ // concurrency outcome the framework returns verbatim and the client
449
+ // rebases from, instead of a Sentry-captured fault + retry storm.
450
+ const conflict = new Error(
451
+ "File changed since it was read. Re-read the file and retry.",
452
+ ) as Error & { statusCode?: number };
453
+ conflict.statusCode = 409;
454
+ throw conflict;
455
+ }
456
+ }
482
457
  }
483
- }
484
- if (filename !== undefined) updates.filename = filename;
485
- if (fileType !== undefined) updates.fileType = fileType;
486
458
 
487
- // The JS lock above is intentionally fast but process-local. Versioned
488
- // browser writes also need a database CAS so two serverless instances
489
- // cannot both validate the same snapshot and let the later SQL update
490
- // clobber the winner. If another instance moves any part of the content
491
- // lineage first, rowsAffected is zero and this loop re-reads the row;
492
- // the next pass then classifies the request as a stale no-op or a real
493
- // source-version conflict.
494
- const requiresContentCas =
495
- hasVersionedContentOperation && !skipContentWrite;
496
- const contentCasWhere = requiresContentCas
497
- ? and(
498
- eq(schema.designFiles.content, persistedFile.content),
499
- persistedFile.contentOperationSource == null
500
- ? isNull(schema.designFiles.contentOperationSource)
501
- : eq(
502
- schema.designFiles.contentOperationSource,
503
- persistedFile.contentOperationSource,
504
- ),
505
- persistedFile.contentOperationRevision == null
506
- ? isNull(schema.designFiles.contentOperationRevision)
507
- : eq(
508
- schema.designFiles.contentOperationRevision,
509
- persistedFile.contentOperationRevision,
510
- ),
511
- persistedFile.contentOperationResultHash == null
512
- ? isNull(schema.designFiles.contentOperationResultHash)
513
- : eq(
514
- schema.designFiles.contentOperationResultHash,
515
- persistedFile.contentOperationResultHash,
516
- ),
517
- )
518
- : undefined;
459
+ const updates: Record<string, unknown> = { updatedAt: now };
460
+ if (content !== undefined && !skipContentWrite) {
461
+ updates.content = content;
462
+ persistedVersionHash = sourceContentHash(content);
463
+ if (
464
+ operationSource !== undefined &&
465
+ operationRevision !== undefined
466
+ ) {
467
+ updates.contentOperationSource = operationSource;
468
+ updates.contentOperationRevision = operationRevision;
469
+ updates.contentOperationResultHash = persistedVersionHash;
470
+ } else {
471
+ // An unversioned writer starts a different lineage. Clearing the
472
+ // browser operation marker prevents a later request from treating
473
+ // stale transport metadata as proof that no writer intervened.
474
+ updates.contentOperationSource = null;
475
+ updates.contentOperationRevision = null;
476
+ updates.contentOperationResultHash = null;
477
+ }
478
+ }
479
+ if (filename !== undefined) updates.filename = filename;
480
+ if (fileType !== undefined) updates.fileType = fileType;
481
+
482
+ // The JS lock above is intentionally fast but process-local. Versioned
483
+ // browser writes also need a database CAS so two serverless instances
484
+ // cannot both validate the same snapshot and let the later SQL update
485
+ // clobber the winner. If another instance moves any part of the content
486
+ // lineage first, rowsAffected is zero and this loop re-reads the row;
487
+ // the next pass then classifies the request as a stale no-op or a real
488
+ // source-version conflict.
489
+ const requiresContentCas =
490
+ hasVersionedContentOperation && !skipContentWrite;
491
+ const contentCasWhere = requiresContentCas
492
+ ? and(
493
+ eq(schema.designFiles.content, persistedFile.content),
494
+ persistedFile.contentOperationSource == null
495
+ ? isNull(schema.designFiles.contentOperationSource)
496
+ : eq(
497
+ schema.designFiles.contentOperationSource,
498
+ persistedFile.contentOperationSource,
499
+ ),
500
+ persistedFile.contentOperationRevision == null
501
+ ? isNull(schema.designFiles.contentOperationRevision)
502
+ : eq(
503
+ schema.designFiles.contentOperationRevision,
504
+ persistedFile.contentOperationRevision,
505
+ ),
506
+ persistedFile.contentOperationResultHash == null
507
+ ? isNull(schema.designFiles.contentOperationResultHash)
508
+ : eq(
509
+ schema.designFiles.contentOperationResultHash,
510
+ persistedFile.contentOperationResultHash,
511
+ ),
512
+ )
513
+ : undefined;
519
514
 
520
- let updateResult: unknown;
515
+ let updateResult: unknown;
521
516
 
522
- if (filename !== undefined) {
523
- updateResult = await db.transaction(async (tx) => {
524
- // A guarded UPDATE alone can still race under Postgres MVCC, so
525
- // serialize design-file renames before checking for collisions.
526
- await (
527
- tx as unknown as {
528
- execute: (query: unknown) => Promise<unknown>;
529
- }
530
- ).execute(sql`LOCK TABLE design_files IN SHARE ROW EXCLUSIVE MODE`);
517
+ if (filename !== undefined) {
518
+ // The surrounding design-scoped transaction already holds the
519
+ // advisory lock, so collision validation and the guarded update share
520
+ // one serialized boundary.
531
521
  const [collision] = await tx
532
522
  .select({ id: schema.designFiles.id })
533
523
  .from(schema.designFiles)
@@ -543,133 +533,140 @@ export default defineAction({
543
533
  `File "${filename}" already exists in design ${file.designId}`,
544
534
  );
545
535
  }
546
- return tx
536
+ updateResult = await tx
547
537
  .update(schema.designFiles)
548
538
  .set(updates)
549
539
  .where(and(eq(schema.designFiles.id, id), contentCasWhere));
550
- });
551
- } else {
552
- updateResult = await db
553
- .update(schema.designFiles)
554
- .set(updates)
555
- .where(and(eq(schema.designFiles.id, id), contentCasWhere));
556
- }
557
-
558
- if (requiresContentCas && rowsAffected(updateResult) === 0) {
559
- continue;
560
- }
540
+ } else {
541
+ updateResult = await tx
542
+ .update(schema.designFiles)
543
+ .set(updates)
544
+ .where(and(eq(schema.designFiles.id, id), contentCasWhere));
545
+ }
561
546
 
562
- if (requiresContentCas && rowsAffected(updateResult) === undefined) {
563
- const [confirmed] = await db
564
- .select({
565
- content: schema.designFiles.content,
566
- contentOperationSource: schema.designFiles.contentOperationSource,
567
- contentOperationRevision:
568
- schema.designFiles.contentOperationRevision,
569
- contentOperationResultHash:
570
- schema.designFiles.contentOperationResultHash,
571
- })
572
- .from(schema.designFiles)
573
- .where(eq(schema.designFiles.id, id))
574
- .limit(1);
575
- if (!confirmed) throw fileNotFound(id);
576
- const confirmedHash = sourceContentHash(confirmed.content);
577
- const exactOperationPersisted =
578
- confirmed.contentOperationSource === operationSource &&
579
- confirmed.contentOperationRevision === operationRevision &&
580
- confirmed.contentOperationResultHash === persistedVersionHash &&
581
- confirmedHash === persistedVersionHash;
582
- if (!exactOperationPersisted) {
583
- if (
584
- confirmed.contentOperationSource === operationSource &&
585
- typeof confirmed.contentOperationRevision === "number" &&
586
- operationRevision !== undefined &&
587
- confirmed.contentOperationRevision >= operationRevision
588
- ) {
589
- skippedStaleOperation = true;
590
- persistedVersionHash = confirmedHash;
591
- return;
592
- }
547
+ if (requiresContentCas && affectedRowCount(updateResult) === 0) {
593
548
  continue;
594
549
  }
595
- }
596
550
 
597
- // Only the declared server writer authors CRDT operations. A mirror-only
598
- // save must not duplicate a delayed client's insertions.
599
- const shouldConvergePersistedRetry =
600
- exactOperationAlreadyPersisted && syncCollab;
601
- if (
602
- content !== undefined &&
603
- (!skipContentWrite || shouldConvergePersistedRetry) &&
604
- syncCollab
605
- ) {
606
- const collabExists = await hasCollabState(id);
607
- if (collabExists) {
608
- await applyText(id, content, "content", "agent");
609
- } else {
610
- await seedFromText(id, content);
551
+ if (
552
+ requiresContentCas &&
553
+ affectedRowCount(updateResult) === undefined
554
+ ) {
555
+ const [confirmed] = await tx
556
+ .select({
557
+ content: schema.designFiles.content,
558
+ contentOperationSource:
559
+ schema.designFiles.contentOperationSource,
560
+ contentOperationRevision:
561
+ schema.designFiles.contentOperationRevision,
562
+ contentOperationResultHash:
563
+ schema.designFiles.contentOperationResultHash,
564
+ })
565
+ .from(schema.designFiles)
566
+ .where(eq(schema.designFiles.id, id))
567
+ .limit(1);
568
+ if (!confirmed) throw fileNotFound(id);
569
+ const confirmedHash = sourceContentHash(confirmed.content);
570
+ const exactOperationPersisted =
571
+ confirmed.contentOperationSource === operationSource &&
572
+ confirmed.contentOperationRevision === operationRevision &&
573
+ confirmed.contentOperationResultHash === persistedVersionHash &&
574
+ confirmedHash === persistedVersionHash;
575
+ if (!exactOperationPersisted) {
576
+ if (
577
+ confirmed.contentOperationSource === operationSource &&
578
+ typeof confirmed.contentOperationRevision === "number" &&
579
+ operationRevision !== undefined &&
580
+ confirmed.contentOperationRevision >= operationRevision
581
+ ) {
582
+ skippedStaleOperation = true;
583
+ persistedVersionHash = confirmedHash;
584
+ await tx
585
+ .update(schema.designs)
586
+ .set({ updatedAt: now })
587
+ .where(eq(schema.designs.id, file.designId));
588
+ return;
589
+ }
590
+ continue;
591
+ }
611
592
  }
612
593
 
613
- // SQL CAS is cross-instance, while the collab document uses a
614
- // separate transport. If another instance committed a newer SQL
615
- // revision while this request was applying its text diff, converge
616
- // the live document back to the current SQL winner before returning.
617
- // Whichever writer finishes last performs this same check, so a late
618
- // older collab apply cannot leave Yjs behind the monotonic mirror.
619
- const [latestPersisted] = await db
620
- .select({
621
- content: schema.designFiles.content,
622
- contentOperationSource: schema.designFiles.contentOperationSource,
623
- contentOperationRevision:
624
- schema.designFiles.contentOperationRevision,
625
- })
626
- .from(schema.designFiles)
627
- .where(eq(schema.designFiles.id, id))
628
- .limit(1);
629
- if (latestPersisted && latestPersisted.content !== content) {
630
- const collabStillExists = await hasCollabState(id);
631
- if (collabStillExists) {
632
- await applyText(id, latestPersisted.content, "content", "agent");
594
+ // Only the declared server writer authors CRDT operations. A mirror-only
595
+ // save must not duplicate a delayed client's insertions.
596
+ const shouldConvergePersistedRetry =
597
+ exactOperationAlreadyPersisted && syncCollab;
598
+ if (
599
+ content !== undefined &&
600
+ (!skipContentWrite || shouldConvergePersistedRetry) &&
601
+ syncCollab
602
+ ) {
603
+ const collabExists = await hasCollabState(id);
604
+ if (collabExists) {
605
+ await applyText(id, content, "content", "agent");
633
606
  } else {
634
- await seedFromText(id, latestPersisted.content);
607
+ await seedFromText(id, content);
635
608
  }
636
- persistedVersionHash = sourceContentHash(latestPersisted.content);
637
- if (
638
- latestPersisted.contentOperationSource === operationSource &&
639
- typeof latestPersisted.contentOperationRevision === "number" &&
640
- operationRevision !== undefined &&
641
- latestPersisted.contentOperationRevision >= operationRevision
642
- ) {
643
- skippedStaleOperation = true;
609
+
610
+ // SQL CAS is cross-instance, while the collab document uses a
611
+ // separate transport. If another instance committed a newer SQL
612
+ // revision while this request was applying its text diff, converge
613
+ // the live document back to the current SQL winner before returning.
614
+ // Whichever writer finishes last performs this same check, so a late
615
+ // older collab apply cannot leave Yjs behind the monotonic mirror.
616
+ const [latestPersisted] = await tx
617
+ .select({
618
+ content: schema.designFiles.content,
619
+ contentOperationSource:
620
+ schema.designFiles.contentOperationSource,
621
+ contentOperationRevision:
622
+ schema.designFiles.contentOperationRevision,
623
+ })
624
+ .from(schema.designFiles)
625
+ .where(eq(schema.designFiles.id, id))
626
+ .limit(1);
627
+ if (latestPersisted && latestPersisted.content !== content) {
628
+ const collabStillExists = await hasCollabState(id);
629
+ if (collabStillExists) {
630
+ await applyText(
631
+ id,
632
+ latestPersisted.content,
633
+ "content",
634
+ "agent",
635
+ );
636
+ } else {
637
+ await seedFromText(id, latestPersisted.content);
638
+ }
639
+ persistedVersionHash = sourceContentHash(latestPersisted.content);
640
+ if (
641
+ latestPersisted.contentOperationSource === operationSource &&
642
+ typeof latestPersisted.contentOperationRevision === "number" &&
643
+ operationRevision !== undefined &&
644
+ latestPersisted.contentOperationRevision >= operationRevision
645
+ ) {
646
+ skippedStaleOperation = true;
647
+ }
644
648
  }
645
649
  }
650
+ await tx
651
+ .update(schema.designs)
652
+ .set({ updatedAt: now })
653
+ .where(eq(schema.designs.id, file.designId));
654
+ return;
646
655
  }
647
- return;
648
- }
649
- logSaveConflictDebug("retry-exhausted", {
650
- id,
651
- caller: context?.caller,
652
- operationSource: operationSource ?? null,
653
- operationRevision: operationRevision ?? null,
654
- expectedVersionHash,
655
- });
656
- const exhausted = new Error(
657
- "File changed repeatedly while it was being saved. Re-read the file and retry.",
658
- ) as Error & { statusCode?: number };
659
- exhausted.statusCode = 409;
660
- throw exhausted;
661
- });
662
-
663
- // Update the parent design's updatedAt timestamp. This still runs even
664
- // when the content write was skipped (skippedStaleMirror): filename/
665
- // fileType may have changed in the same call, and even a content-only
666
- // call that hit the skip path still represents a real request the design
667
- // was touched by, matching this action's existing unconditional-bump
668
- // contract for every other call shape.
669
- await db
670
- .update(schema.designs)
671
- .set({ updatedAt: now })
672
- .where(eq(schema.designs.id, file.designId));
656
+ logSaveConflictDebug("retry-exhausted", {
657
+ id,
658
+ caller: context?.caller,
659
+ operationSource: operationSource ?? null,
660
+ operationRevision: operationRevision ?? null,
661
+ expectedVersionHash,
662
+ });
663
+ const exhausted = new Error(
664
+ "File changed repeatedly while it was being saved. Re-read the file and retry.",
665
+ ) as Error & { statusCode?: number };
666
+ exhausted.statusCode = 409;
667
+ throw exhausted;
668
+ }),
669
+ );
673
670
 
674
671
  if (skippedStaleMirror) {
675
672
  return { id, updated: true, skippedStaleMirror: true };