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

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 (66) 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 +1 -0
  9. package/corpus/templates/design/actions/add-localhost-screens.ts +54 -22
  10. package/corpus/templates/design/actions/apply-visual-edit.ts +1 -0
  11. package/corpus/templates/design/actions/create-design-from-template.ts +50 -35
  12. package/corpus/templates/design/actions/create-design-version.ts +21 -0
  13. package/corpus/templates/design/actions/create-file.ts +35 -33
  14. package/corpus/templates/design/actions/delete-design.ts +29 -28
  15. package/corpus/templates/design/actions/delete-file.ts +154 -23
  16. package/corpus/templates/design/actions/generate-design.ts +28 -16
  17. package/corpus/templates/design/actions/get-component-details.ts +187 -33
  18. package/corpus/templates/design/actions/get-design-version.ts +9 -1
  19. package/corpus/templates/design/actions/go-to-main-component.ts +37 -35
  20. package/corpus/templates/design/actions/index-components.ts +241 -126
  21. package/corpus/templates/design/actions/migrate-board-objects-to-file.ts +12 -9
  22. package/corpus/templates/design/actions/present-design-variants.ts +68 -72
  23. package/corpus/templates/design/actions/rename-component.ts +294 -0
  24. package/corpus/templates/design/actions/rename-screen.ts +10 -10
  25. package/corpus/templates/design/actions/update-file.ts +327 -330
  26. package/corpus/templates/design/actions/update-screen-source.ts +2 -43
  27. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +1 -0
  28. package/corpus/templates/design/app/components/design/code-workbench/CodeWorkbench.tsx +8 -1
  29. package/corpus/templates/design/app/components/design/code-workbench/explorer/FileTree.tsx +55 -33
  30. package/corpus/templates/design/app/components/design/code-workbench/workspace/create-providers.ts +2 -0
  31. package/corpus/templates/design/app/components/design/code-workbench/workspace/inline-provider.ts +4 -2
  32. package/corpus/templates/design/app/components/design/edit-panel/element-classification.ts +5 -1
  33. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +57 -9
  34. package/corpus/templates/design/app/components/design/types.ts +1 -0
  35. package/corpus/templates/design/app/hooks/use-design-versions.ts +3 -2
  36. package/corpus/templates/design/app/i18n-breakpoints.ts +0 -110
  37. package/corpus/templates/design/app/i18n-data.ts +0 -10
  38. package/corpus/templates/design/app/pages/DesignEditor.tsx +250 -107
  39. package/corpus/templates/design/app/pages/design-editor/commands/apply-file-content-update.ts +2 -0
  40. package/corpus/templates/design/app/pages/design-editor/commands/delete-files.ts +69 -47
  41. package/corpus/templates/design/app/pages/design-editor/commands/redo.ts +8 -1
  42. package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +107 -2
  43. package/corpus/templates/design/app/pages/design-editor/commands/undo.ts +26 -1
  44. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +63 -2
  45. package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +74 -22
  46. package/corpus/templates/design/app/pages/design-editor/source-publication.ts +14 -2
  47. package/corpus/templates/design/changelog/2026-09-16-design-components-retain-linked-identities-when-edited-and-d.md +6 -0
  48. package/corpus/templates/design/changelog/2026-09-17-screen-deletion-is-immediate-and-undoable-with-history-that-.md +6 -0
  49. package/corpus/templates/design/e2e/fixtures/design-component-react/index.html +98 -0
  50. package/corpus/templates/design/e2e/fixtures/design-component-react/inline-component.html +65 -0
  51. package/corpus/templates/design/server/lib/design-data-mutation.ts +9 -5
  52. package/corpus/templates/design/server/lib/design-snapshot.ts +28 -16
  53. package/corpus/templates/design/server/lib/design-versions.ts +208 -130
  54. package/corpus/templates/design/server/lib/fusion-screens.ts +46 -20
  55. package/corpus/templates/design/server/lib/import-design-files.ts +5 -1
  56. package/corpus/templates/design/server/lib/localhost-connection.ts +54 -0
  57. package/corpus/templates/design/server/source-workspace.ts +412 -356
  58. package/corpus/templates/design/shared/component-library.ts +36 -6
  59. package/corpus/templates/design/shared/component-model.ts +8 -2
  60. package/dist/automation/index.d.ts +1 -1
  61. package/dist/client/chat/agentkit-protocol.js +148 -34
  62. package/dist/collab/struct-routes.d.ts +1 -1
  63. package/dist/progress/routes.d.ts +1 -1
  64. package/dist/server/core-routes-plugin.d.ts +1 -1
  65. package/package.json +4 -4
  66. package/corpus/templates/design/app/components/design/editor/PendingScreenDeletionDialog.tsx +0 -66
@@ -9,9 +9,9 @@ import {
9
9
  type PreparedYDocMutationLease,
10
10
  withPreparedYDocMutation,
11
11
  } from "@agent-native/core/collab";
12
- import { getDbExec } from "@agent-native/core/db";
12
+ import { getDbExec, type DbExec } from "@agent-native/core/db";
13
13
  import { assertAccess, resolveAccess } from "@agent-native/core/sharing";
14
- import { and, eq, inArray, isNull } from "drizzle-orm";
14
+ import { and, eq, inArray, isNull, sql } from "drizzle-orm";
15
15
 
16
16
  import { isBoardFile } from "../shared/board-file.js";
17
17
  import { ensureCodeLayerNodeIdsInHtml } from "../shared/code-layer.js";
@@ -65,7 +65,7 @@ const _writeLocks = new Map<string, Promise<void>>();
65
65
  /**
66
66
  * Normalize affected-row metadata from PGlite and hosted Postgres.
67
67
  */
68
- function affectedRowCount(result: unknown): number | undefined {
68
+ export function affectedRowCount(result: unknown): number | undefined {
69
69
  const candidate = result as
70
70
  | {
71
71
  rowsAffected?: unknown;
@@ -86,6 +86,21 @@ function affectedRowCount(result: unknown): number | undefined {
86
86
  return typeof value === "number" ? value : undefined;
87
87
  }
88
88
 
89
+ /**
90
+ * Acquire the shared design-file table lock before any file or design row
91
+ * locks in the design editing mutation boundary.
92
+ */
93
+ export async function lockDesignFilesTable(tx: unknown): Promise<void> {
94
+ const execute = (tx as { execute?: unknown }).execute;
95
+ if (typeof execute !== "function") {
96
+ throw new Error("Design-file transactions must support SQL table locks.");
97
+ }
98
+ await (execute as (query: unknown) => Promise<unknown>).call(
99
+ tx,
100
+ sql`LOCK TABLE design_files IN SHARE ROW EXCLUSIVE MODE`,
101
+ );
102
+ }
103
+
89
104
  // Exported so other write paths touching the same per-file critical section
90
105
  // (content read -> optimistic-concurrency hash check -> collab/SQL write) can
91
106
  // serialize under the SAME lock instead of each guarding independently. Two
@@ -122,6 +137,38 @@ export async function withSourceFileWriteLock<T>(
122
137
  }
123
138
  }
124
139
 
140
+ /** Serialize cross-process mutations that reconcile a design's source/index. */
141
+ export function designSourceMutationLockKey(designId: string): string {
142
+ return `agent-native:design-source:${designId}`;
143
+ }
144
+
145
+ export async function lockDesignSourceMutation(
146
+ tx: DbExec,
147
+ designId: string,
148
+ ): Promise<void> {
149
+ await tx.execute({
150
+ sql: "SELECT pg_advisory_xact_lock(hashtextextended(?, 0::bigint))",
151
+ args: [designSourceMutationLockKey(designId)],
152
+ });
153
+ }
154
+
155
+ type DesignSourceMutationTransaction = Parameters<
156
+ Parameters<ReturnType<typeof getDb>["transaction"]>[0]
157
+ >[0];
158
+
159
+ /** Keep design-file membership changes in the same lock domain as indexing. */
160
+ export function withDesignSourceMutationTransaction<T>(
161
+ designId: string,
162
+ callback: (tx: DesignSourceMutationTransaction) => Promise<T>,
163
+ ): Promise<T> {
164
+ return getDb().transaction(async (tx) => {
165
+ await tx.execute(
166
+ sql`SELECT pg_advisory_xact_lock(hashtextextended(${designSourceMutationLockKey(designId)}, 0::bigint))`,
167
+ );
168
+ return callback(tx);
169
+ });
170
+ }
171
+
125
172
  export interface SourceWorkspaceContext {
126
173
  designId: string;
127
174
  sourceType: DesignSourceType;
@@ -325,384 +372,391 @@ export async function writeInlineSourceFile(args: {
325
372
  /** Used only when the editor deliberately changes a screen's source mode. */
326
373
  allowUrlBackedTransition?: boolean;
327
374
  }): Promise<{ versionHash: string; changed: boolean; updatedAt: string }> {
328
- return withSourceFileWriteLock(args.file.id, async () => {
329
- await assertAccess("design", args.designId, "editor");
330
- const db = getDb();
331
- const [currentFile] = await db
332
- .select({
333
- id: schema.designFiles.id,
334
- designId: schema.designFiles.designId,
335
- filename: schema.designFiles.filename,
336
- fileType: schema.designFiles.fileType,
337
- content: schema.designFiles.content,
338
- createdAt: schema.designFiles.createdAt,
339
- updatedAt: schema.designFiles.updatedAt,
340
- contentOperationSource: schema.designFiles.contentOperationSource,
341
- contentOperationRevision: schema.designFiles.contentOperationRevision,
342
- contentOperationResultHash:
343
- schema.designFiles.contentOperationResultHash,
344
- })
345
- .from(schema.designFiles)
346
- .where(eq(schema.designFiles.id, args.file.id))
347
- .limit(1);
348
- if (!currentFile || currentFile.designId !== args.designId) {
349
- throw new Error("Source file not found.");
350
- }
351
- const current = await readLiveSourceFile(currentFile);
352
- const identityOnly = args.identityOnly === true;
353
- let identityOnlyOperationSource: string | undefined;
354
- let identityOnlyOperationRevision: number | undefined;
355
- const liveBaseHash = current.versionHash;
356
-
357
- if (
358
- args.expectedVersionHash &&
359
- !identityOnly &&
360
- args.expectedVersionHash !== current.versionHash
361
- ) {
362
- // Typed so callers can catch-and-retry the same way they already do for
363
- // the other two conflict sites below (see apply-shader-fill.ts /
364
- // apply-component-prop-edit.ts / edit-design.ts) instead of only
365
- // matching on message text.
366
- throw new SourceWorkspaceEditConflictError(
367
- "Source file changed since it was read. Re-read the file and retry.",
368
- );
369
- }
370
-
371
- if (identityOnly) {
372
- identityOnlyOperationSource = args.operationSource;
373
- identityOnlyOperationRevision = args.operationRevision;
374
- if (
375
- args.expectedVersionHash === undefined ||
376
- !identityOnlyOperationSource ||
377
- !Number.isSafeInteger(identityOnlyOperationRevision) ||
378
- (identityOnlyOperationRevision ?? 0) <= 0
379
- ) {
380
- throw new SourceWorkspaceEditConflictError(
381
- "An identity-only source write requires its source version and operation lineage.",
382
- );
375
+ return withSourceFileWriteLock(args.file.id, () =>
376
+ withDesignSourceMutationTransaction(args.designId, async (tx) => {
377
+ await assertAccess("design", args.designId, "editor");
378
+ const [currentFile] = await tx
379
+ .select({
380
+ id: schema.designFiles.id,
381
+ designId: schema.designFiles.designId,
382
+ filename: schema.designFiles.filename,
383
+ fileType: schema.designFiles.fileType,
384
+ content: schema.designFiles.content,
385
+ createdAt: schema.designFiles.createdAt,
386
+ updatedAt: schema.designFiles.updatedAt,
387
+ contentOperationSource: schema.designFiles.contentOperationSource,
388
+ contentOperationRevision: schema.designFiles.contentOperationRevision,
389
+ contentOperationResultHash:
390
+ schema.designFiles.contentOperationResultHash,
391
+ })
392
+ .from(schema.designFiles)
393
+ .where(eq(schema.designFiles.id, args.file.id))
394
+ .limit(1);
395
+ if (!currentFile || currentFile.designId !== args.designId) {
396
+ throw new Error("Source file not found.");
383
397
  }
398
+ const current = await readLiveSourceFile(currentFile);
399
+ const identityOnly = args.identityOnly === true;
400
+ let identityOnlyOperationSource: string | undefined;
401
+ let identityOnlyOperationRevision: number | undefined;
402
+ const liveBaseHash = current.versionHash;
403
+
384
404
  if (
385
- currentFile.fileType !== "html" ||
386
- isStandaloneHttpUrl(current.content) ||
387
- isStandaloneHttpUrl(currentFile.content ?? "")
405
+ args.expectedVersionHash &&
406
+ !identityOnly &&
407
+ args.expectedVersionHash !== current.versionHash
388
408
  ) {
409
+ // Typed so callers can catch-and-retry the same way they already do for
410
+ // the other two conflict sites below (see apply-shader-fill.ts /
411
+ // apply-component-prop-edit.ts / edit-design.ts) instead of only
412
+ // matching on message text.
389
413
  throw new SourceWorkspaceEditConflictError(
390
- "Identity-only publication is supported only for inline HTML files.",
414
+ "Source file changed since it was read. Re-read the file and retry.",
391
415
  );
392
416
  }
393
417
 
394
- const canonicalLiveSource = ensureCodeLayerNodeIdsInHtml(
395
- current.content,
396
- { source: { kind: "design-file", fileId: args.file.id } },
397
- ).content;
398
- const canonicalSqlSource = ensureCodeLayerNodeIdsInHtml(
399
- currentFile.content ?? "",
400
- { source: { kind: "design-file", fileId: args.file.id } },
401
- ).content;
402
- if (args.content !== canonicalLiveSource) {
403
- throw new SourceWorkspaceEditConflictError(
404
- "Identity-only publication must contain exactly the source node identity annotations.",
405
- );
418
+ if (identityOnly) {
419
+ identityOnlyOperationSource = args.operationSource;
420
+ identityOnlyOperationRevision = args.operationRevision;
421
+ if (
422
+ args.expectedVersionHash === undefined ||
423
+ !identityOnlyOperationSource ||
424
+ !Number.isSafeInteger(identityOnlyOperationRevision) ||
425
+ (identityOnlyOperationRevision ?? 0) <= 0
426
+ ) {
427
+ throw new SourceWorkspaceEditConflictError(
428
+ "An identity-only source write requires its source version and operation lineage.",
429
+ );
430
+ }
431
+ if (
432
+ currentFile.fileType !== "html" ||
433
+ isStandaloneHttpUrl(current.content) ||
434
+ isStandaloneHttpUrl(currentFile.content ?? "")
435
+ ) {
436
+ throw new SourceWorkspaceEditConflictError(
437
+ "Identity-only publication is supported only for inline HTML files.",
438
+ );
439
+ }
440
+
441
+ const canonicalLiveSource = ensureCodeLayerNodeIdsInHtml(
442
+ current.content,
443
+ { source: { kind: "design-file", fileId: args.file.id } },
444
+ ).content;
445
+ const canonicalSqlSource = ensureCodeLayerNodeIdsInHtml(
446
+ currentFile.content ?? "",
447
+ { source: { kind: "design-file", fileId: args.file.id } },
448
+ ).content;
449
+ if (args.content !== canonicalLiveSource) {
450
+ throw new SourceWorkspaceEditConflictError(
451
+ "Identity-only publication must contain exactly the source node identity annotations.",
452
+ );
453
+ }
454
+
455
+ const candidateHash = sourceContentHash(args.content);
456
+ const exactPersistedOperation =
457
+ currentFile.content === args.content &&
458
+ current.content === args.content &&
459
+ currentFile.contentOperationSource === identityOnlyOperationSource &&
460
+ currentFile.contentOperationRevision ===
461
+ identityOnlyOperationRevision &&
462
+ currentFile.contentOperationResultHash === candidateHash;
463
+ // A retried request may carry the hash of its raw preimage after the
464
+ // first request has already atomically persisted the canonical result.
465
+ // Only the full content + operation-lineage proof above earns this
466
+ // idempotent fast path.
467
+ if (exactPersistedOperation) {
468
+ return {
469
+ versionHash: candidateHash,
470
+ changed: false,
471
+ updatedAt: currentFile.updatedAt ?? new Date().toISOString(),
472
+ };
473
+ }
474
+ if (
475
+ currentFile.contentOperationSource === identityOnlyOperationSource &&
476
+ typeof currentFile.contentOperationRevision === "number" &&
477
+ (identityOnlyOperationRevision ?? 0) <=
478
+ currentFile.contentOperationRevision
479
+ ) {
480
+ throw new SourceWorkspaceEditConflictError(
481
+ "A newer source operation was already accepted for this identity migration.",
482
+ );
483
+ }
484
+
485
+ const rawSqlHash = sourceContentHash(currentFile.content ?? "");
486
+ const expectedHashMatchesLive =
487
+ args.expectedVersionHash === current.versionHash;
488
+ // Local publication can reach Yjs before the corresponding SQL
489
+ // migration request. In that case accept the exact canonical transform
490
+ // only when the request is based on the still-current raw SQL preimage
491
+ // and Yjs already contains that exact transform.
492
+ const expectedHashMatchesPublishedPreimage =
493
+ current.content === args.content &&
494
+ args.expectedVersionHash === rawSqlHash &&
495
+ canonicalSqlSource === args.content;
496
+ const sqlContentIsSameSource =
497
+ currentFile.content === current.content ||
498
+ (current.content === args.content &&
499
+ canonicalSqlSource === args.content);
500
+ if (!expectedHashMatchesLive && !expectedHashMatchesPublishedPreimage) {
501
+ throw new SourceWorkspaceEditConflictError(
502
+ "Source file changed since the identity migration was prepared. Re-read the file and retry.",
503
+ );
504
+ }
505
+ if (!sqlContentIsSameSource) {
506
+ throw new SourceWorkspaceEditConflictError(
507
+ "The SQL source and live document no longer share the identity migration base. Re-read the file and retry.",
508
+ );
509
+ }
406
510
  }
407
511
 
408
- const candidateHash = sourceContentHash(args.content);
409
- const exactPersistedOperation =
410
- currentFile.content === args.content &&
411
- current.content === args.content &&
412
- currentFile.contentOperationSource === identityOnlyOperationSource &&
413
- currentFile.contentOperationRevision ===
414
- identityOnlyOperationRevision &&
415
- currentFile.contentOperationResultHash === candidateHash;
416
- // A retried request may carry the hash of its raw preimage after the
417
- // first request has already atomically persisted the canonical result.
418
- // Only the full content + operation-lineage proof above earns this
419
- // idempotent fast path.
420
- if (exactPersistedOperation) {
512
+ const changed = args.content !== current.content;
513
+ const updatedAt = new Date().toISOString();
514
+ if (!changed && !identityOnly) {
421
515
  return {
422
- versionHash: candidateHash,
516
+ versionHash: current.versionHash,
423
517
  changed: false,
424
- updatedAt: currentFile.updatedAt ?? new Date().toISOString(),
518
+ updatedAt: currentFile.updatedAt ?? updatedAt,
425
519
  };
426
520
  }
427
- if (
428
- currentFile.contentOperationSource === identityOnlyOperationSource &&
429
- typeof currentFile.contentOperationRevision === "number" &&
430
- (identityOnlyOperationRevision ?? 0) <=
431
- currentFile.contentOperationRevision
432
- ) {
433
- throw new SourceWorkspaceEditConflictError(
434
- "A newer source operation was already accepted for this identity migration.",
435
- );
436
- }
437
-
438
- const rawSqlHash = sourceContentHash(currentFile.content ?? "");
439
- const expectedHashMatchesLive =
440
- args.expectedVersionHash === current.versionHash;
441
- // Local publication can reach Yjs before the corresponding SQL
442
- // migration request. In that case accept the exact canonical transform
443
- // only when the request is based on the still-current raw SQL preimage
444
- // and Yjs already contains that exact transform.
445
- const expectedHashMatchesPublishedPreimage =
446
- current.content === args.content &&
447
- args.expectedVersionHash === rawSqlHash &&
448
- canonicalSqlSource === args.content;
449
- const sqlContentIsSameSource =
450
- currentFile.content === current.content ||
451
- (current.content === args.content &&
452
- canonicalSqlSource === args.content);
453
- if (!expectedHashMatchesLive && !expectedHashMatchesPublishedPreimage) {
454
- throw new SourceWorkspaceEditConflictError(
455
- "Source file changed since the identity migration was prepared. Re-read the file and retry.",
456
- );
457
- }
458
- if (!sqlContentIsSameSource) {
459
- throw new SourceWorkspaceEditConflictError(
460
- "The SQL source and live document no longer share the identity migration base. Re-read the file and retry.",
461
- );
462
- }
463
- }
464
521
 
465
- const changed = args.content !== current.content;
466
- const updatedAt = new Date().toISOString();
467
- if (!changed && !identityOnly) {
468
- return {
469
- versionHash: current.versionHash,
470
- changed: false,
471
- updatedAt: currentFile.updatedAt ?? updatedAt,
522
+ if (!identityOnly)
523
+ assertLockedLayersPreserved(current.content, args.content);
524
+ const assertCandidateIntegrity = (candidate: string) => {
525
+ if (identityOnly && candidate !== args.content) {
526
+ throw new SourceWorkspaceEditConflictError(
527
+ "A concurrent source edit prevented identity-only publication.",
528
+ );
529
+ }
530
+ const isSourceModeTransition =
531
+ args.allowUrlBackedTransition === true &&
532
+ (isStandaloneHttpUrl(current.content) ||
533
+ isStandaloneHttpUrl(candidate));
534
+ assertDesignHtmlEditIntegrity({
535
+ // A deliberate mode transition must validate a new HTML document as a
536
+ // document, not compare it with the old route string.
537
+ previousContent: isSourceModeTransition ? candidate : current.content,
538
+ nextContent: candidate,
539
+ fileType: currentFile.fileType ?? args.file.fileType ?? "html",
540
+ filename: currentFile.filename ?? args.file.filename,
541
+ });
472
542
  };
473
- }
474
-
475
- if (!identityOnly)
476
- assertLockedLayersPreserved(current.content, args.content);
477
- const assertCandidateIntegrity = (candidate: string) => {
478
- if (identityOnly && candidate !== args.content) {
479
- throw new SourceWorkspaceEditConflictError(
480
- "A concurrent source edit prevented identity-only publication.",
481
- );
482
- }
483
- const isSourceModeTransition =
484
- args.allowUrlBackedTransition === true &&
485
- (isStandaloneHttpUrl(current.content) ||
486
- isStandaloneHttpUrl(candidate));
487
- assertDesignHtmlEditIntegrity({
488
- // A deliberate mode transition must validate a new HTML document as a
489
- // document, not compare it with the old route string.
490
- previousContent: isSourceModeTransition ? candidate : current.content,
491
- nextContent: candidate,
492
- fileType: currentFile.fileType ?? args.file.fileType ?? "html",
493
- filename: currentFile.filename ?? args.file.filename,
494
- });
495
- };
496
- assertCandidateIntegrity(args.content);
543
+ assertCandidateIntegrity(args.content);
497
544
 
498
- if (await hasCollabState(args.file.id)) {
499
- const liveBeforeApply = await getText(args.file.id, "content");
500
- const expectedLiveHash = identityOnly
501
- ? liveBaseHash
502
- : args.expectedVersionHash;
503
- if (
504
- expectedLiveHash &&
505
- expectedLiveHash !== sourceContentHash(liveBeforeApply)
506
- ) {
507
- throw new SourceWorkspaceEditConflictError(
508
- "Source file changed since it was read. Re-read the file and retry.",
509
- );
510
- }
511
- if (liveBeforeApply !== args.content) {
512
- try {
513
- await applyText(args.file.id, args.content, "content", "agent", {
514
- // The check above ran before the write lock and against a value
515
- // that another serverless process can invalidate a millisecond
516
- // later. Re-assert it on the text the diff is actually computed
517
- // from: `args.content` is a whole document built on the older
518
- // base, so a peer's edit that landed in between would be
519
- // overwritten silently rather than reported as a conflict.
520
- validateBase: (base) => {
521
- if (
522
- expectedLiveHash &&
523
- expectedLiveHash !== sourceContentHash(base)
524
- ) {
525
- throw new SourceWorkspaceEditConflictError(
526
- "Source file changed while the edit was being applied. Re-read the file and retry.",
527
- );
528
- }
529
- },
530
- // A human artboard edit can reach the shared Y.Doc from another
531
- // serverless process after the version check above. Validate the
532
- // fully converged CRDT snapshot before core persists or broadcasts
533
- // the agent diff so clients never observe a malformed intermediate
534
- // document that is immediately rolled back below.
535
- validateSnapshot: (snapshot) => assertCandidateIntegrity(snapshot),
536
- });
537
- } catch (error) {
538
- // A peer that commits after the base check now fails the persistence
539
- // CAS by name; it is the same retryable conflict, not a bad edit.
540
- if (error instanceof CollabBaseVersionConflictError) {
545
+ if (await hasCollabState(args.file.id)) {
546
+ const liveBeforeApply = await getText(args.file.id, "content");
547
+ const expectedLiveHash = identityOnly
548
+ ? liveBaseHash
549
+ : args.expectedVersionHash;
550
+ if (
551
+ expectedLiveHash &&
552
+ expectedLiveHash !== sourceContentHash(liveBeforeApply)
553
+ ) {
554
+ throw new SourceWorkspaceEditConflictError(
555
+ "Source file changed since it was read. Re-read the file and retry.",
556
+ );
557
+ }
558
+ if (liveBeforeApply !== args.content) {
559
+ try {
560
+ await applyText(args.file.id, args.content, "content", "agent", {
561
+ // The check above ran before the write lock and against a value
562
+ // that another serverless process can invalidate a millisecond
563
+ // later. Re-assert it on the text the diff is actually computed
564
+ // from: `args.content` is a whole document built on the older
565
+ // base, so a peer's edit that landed in between would be
566
+ // overwritten silently rather than reported as a conflict.
567
+ validateBase: (base) => {
568
+ if (
569
+ expectedLiveHash &&
570
+ expectedLiveHash !== sourceContentHash(base)
571
+ ) {
572
+ throw new SourceWorkspaceEditConflictError(
573
+ "Source file changed while the edit was being applied. Re-read the file and retry.",
574
+ );
575
+ }
576
+ },
577
+ // A human artboard edit can reach the shared Y.Doc from another
578
+ // serverless process after the version check above. Validate the
579
+ // fully converged CRDT snapshot before core persists or broadcasts
580
+ // the agent diff so clients never observe a malformed intermediate
581
+ // document that is immediately rolled back below.
582
+ validateSnapshot: (snapshot) =>
583
+ assertCandidateIntegrity(snapshot),
584
+ });
585
+ } catch (error) {
586
+ // A peer that commits after the base check now fails the persistence
587
+ // CAS by name; it is the same retryable conflict, not a bad edit.
588
+ if (error instanceof CollabBaseVersionConflictError) {
589
+ throw new SourceWorkspaceEditConflictError(
590
+ "Source file changed while the edit was being applied. Re-read the file and retry.",
591
+ );
592
+ }
593
+ if (!isDesignHtmlIntegrityError(error)) throw error;
594
+ // The caller's candidate already passed the integrity check above.
595
+ // A failure here therefore came from concurrent CRDT convergence,
596
+ // so surface it as a retryable conflict instead of blaming the edit
597
+ // with the invalid-HTML toast.
541
598
  throw new SourceWorkspaceEditConflictError(
542
599
  "Source file changed while the edit was being applied. Re-read the file and retry.",
543
600
  );
544
601
  }
545
- if (!isDesignHtmlIntegrityError(error)) throw error;
546
- // The caller's candidate already passed the integrity check above.
547
- // A failure here therefore came from concurrent CRDT convergence,
548
- // so surface it as a retryable conflict instead of blaming the edit
549
- // with the invalid-HTML toast.
602
+ }
603
+ } else {
604
+ // No collab doc exists for this file yet. Without the write-lock this
605
+ // function is now wrapped in, two concurrent callers could both
606
+ // observe hasCollabState()===false (doc creation is lazy) and both
607
+ // reach seedFromText — which only takes effect for the first caller —
608
+ // so a naive unconditional SQL write after this branch could clobber
609
+ // whichever writer "won" the seed with a loser's stale content (a lost
610
+ // update, not merely a no-op: exactly the "assets disappear/reappear"
611
+ // bug this fix closes). The lock serializes this whole critical
612
+ // section per file id, so by the time a second call reaches this
613
+ // branch it already observes hasCollabState()===true from the first
614
+ // call's seed and takes the applyText branch instead.
615
+ await seedFromText(args.file.id, args.content);
616
+ }
617
+
618
+ // Persist whatever the collab layer actually holds now, not the caller's
619
+ // args.content blindly — normally the same string, but this keeps SQL a
620
+ // true mirror of the converged live document under the lock rather than
621
+ // trusting args.content directly.
622
+ const authoritativeContent = await getText(args.file.id, "content");
623
+ try {
624
+ if (identityOnly && authoritativeContent !== args.content) {
550
625
  throw new SourceWorkspaceEditConflictError(
551
- "Source file changed while the edit was being applied. Re-read the file and retry.",
626
+ "A concurrent source edit prevented identity-only publication.",
552
627
  );
553
628
  }
629
+ const isSourceModeTransition =
630
+ args.allowUrlBackedTransition === true &&
631
+ (isStandaloneHttpUrl(current.content) ||
632
+ isStandaloneHttpUrl(authoritativeContent));
633
+ assertDesignHtmlEditIntegrity({
634
+ previousContent: isSourceModeTransition
635
+ ? authoritativeContent
636
+ : current.content,
637
+ nextContent: authoritativeContent,
638
+ fileType: currentFile.fileType ?? args.file.fileType ?? "html",
639
+ });
640
+ } catch (error) {
641
+ // `applyText` is a full-target diff, but keep the write transaction
642
+ // fail-closed even if a malformed/concurrent collab state somehow
643
+ // converges to something other than the validated candidate. Restore
644
+ // the exact pre-write live content before SQL can observe corruption.
645
+ if (!identityOnly) {
646
+ await applyText(args.file.id, current.content, "content", "agent");
647
+ }
648
+ throw error;
554
649
  }
555
- } else {
556
- // No collab doc exists for this file yet. Without the write-lock this
557
- // function is now wrapped in, two concurrent callers could both
558
- // observe hasCollabState()===false (doc creation is lazy) and both
559
- // reach seedFromText — which only takes effect for the first caller —
560
- // so a naive unconditional SQL write after this branch could clobber
561
- // whichever writer "won" the seed with a loser's stale content (a lost
562
- // update, not merely a no-op: exactly the "assets disappear/reappear"
563
- // bug this fix closes). The lock serializes this whole critical
564
- // section per file id, so by the time a second call reaches this
565
- // branch it already observes hasCollabState()===true from the first
566
- // call's seed and takes the applyText branch instead.
567
- await seedFromText(args.file.id, args.content);
568
- }
569
650
 
570
- // Persist whatever the collab layer actually holds now, not the caller's
571
- // args.content blindly — normally the same string, but this keeps SQL a
572
- // true mirror of the converged live document under the lock rather than
573
- // trusting args.content directly.
574
- const authoritativeContent = await getText(args.file.id, "content");
575
- try {
576
- if (identityOnly && authoritativeContent !== args.content) {
577
- throw new SourceWorkspaceEditConflictError(
578
- "A concurrent source edit prevented identity-only publication.",
651
+ // The JS lock is process-local. Guard the SQL mirror with the exact
652
+ // content + revision read above so a writer on another instance cannot
653
+ // commit between our read/live-doc mutation and this final persistence.
654
+ const identityLineageWhere = identityOnly
655
+ ? [
656
+ currentFile.contentOperationSource == null
657
+ ? isNull(schema.designFiles.contentOperationSource)
658
+ : eq(
659
+ schema.designFiles.contentOperationSource,
660
+ currentFile.contentOperationSource,
661
+ ),
662
+ currentFile.contentOperationRevision == null
663
+ ? isNull(schema.designFiles.contentOperationRevision)
664
+ : eq(
665
+ schema.designFiles.contentOperationRevision,
666
+ currentFile.contentOperationRevision,
667
+ ),
668
+ currentFile.contentOperationResultHash == null
669
+ ? isNull(schema.designFiles.contentOperationResultHash)
670
+ : eq(
671
+ schema.designFiles.contentOperationResultHash,
672
+ currentFile.contentOperationResultHash,
673
+ ),
674
+ ]
675
+ : [];
676
+ const updateResult = await tx
677
+ .update(schema.designFiles)
678
+ .set({
679
+ content: authoritativeContent,
680
+ updatedAt,
681
+ contentOperationSource: identityOnly
682
+ ? identityOnlyOperationSource
683
+ : null,
684
+ contentOperationRevision: identityOnly
685
+ ? identityOnlyOperationRevision
686
+ : null,
687
+ contentOperationResultHash: identityOnly
688
+ ? sourceContentHash(authoritativeContent)
689
+ : null,
690
+ })
691
+ .where(
692
+ and(
693
+ eq(schema.designFiles.id, args.file.id),
694
+ eq(schema.designFiles.designId, args.designId),
695
+ eq(schema.designFiles.content, currentFile.content),
696
+ currentFile.updatedAt === null
697
+ ? isNull(schema.designFiles.updatedAt)
698
+ : eq(schema.designFiles.updatedAt, currentFile.updatedAt),
699
+ ...identityLineageWhere,
700
+ ),
579
701
  );
580
- }
581
- const isSourceModeTransition =
582
- args.allowUrlBackedTransition === true &&
583
- (isStandaloneHttpUrl(current.content) ||
584
- isStandaloneHttpUrl(authoritativeContent));
585
- assertDesignHtmlEditIntegrity({
586
- previousContent: isSourceModeTransition
587
- ? authoritativeContent
588
- : current.content,
589
- nextContent: authoritativeContent,
590
- fileType: currentFile.fileType ?? args.file.fileType ?? "html",
591
- });
592
- } catch (error) {
593
- // `applyText` is a full-target diff, but keep the write transaction
594
- // fail-closed even if a malformed/concurrent collab state somehow
595
- // converges to something other than the validated candidate. Restore
596
- // the exact pre-write live content before SQL can observe corruption.
597
- if (!identityOnly) {
598
- await applyText(args.file.id, current.content, "content", "agent");
599
- }
600
- throw error;
601
- }
602
-
603
- // The JS lock is process-local. Guard the SQL mirror with the exact
604
- // content + revision read above so a writer on another instance cannot
605
- // commit between our read/live-doc mutation and this final persistence.
606
- const identityLineageWhere = identityOnly
607
- ? [
608
- currentFile.contentOperationSource == null
609
- ? isNull(schema.designFiles.contentOperationSource)
610
- : eq(
611
- schema.designFiles.contentOperationSource,
612
- currentFile.contentOperationSource,
613
- ),
614
- currentFile.contentOperationRevision == null
615
- ? isNull(schema.designFiles.contentOperationRevision)
616
- : eq(
617
- schema.designFiles.contentOperationRevision,
618
- currentFile.contentOperationRevision,
619
- ),
620
- currentFile.contentOperationResultHash == null
621
- ? isNull(schema.designFiles.contentOperationResultHash)
622
- : eq(
623
- schema.designFiles.contentOperationResultHash,
624
- currentFile.contentOperationResultHash,
625
- ),
626
- ]
627
- : [];
628
- const updateResult = await db
629
- .update(schema.designFiles)
630
- .set({
631
- content: authoritativeContent,
632
- updatedAt,
633
- contentOperationSource: identityOnly
634
- ? identityOnlyOperationSource
635
- : null,
636
- contentOperationRevision: identityOnly
637
- ? identityOnlyOperationRevision
638
- : null,
639
- contentOperationResultHash: identityOnly
640
- ? sourceContentHash(authoritativeContent)
641
- : null,
642
- })
643
- .where(
644
- and(
645
- eq(schema.designFiles.id, args.file.id),
646
- eq(schema.designFiles.designId, args.designId),
647
- eq(schema.designFiles.content, currentFile.content),
648
- currentFile.updatedAt === null
649
- ? isNull(schema.designFiles.updatedAt)
650
- : eq(schema.designFiles.updatedAt, currentFile.updatedAt),
651
- ...identityLineageWhere,
652
- ),
653
- );
654
702
 
655
- const affected = affectedRowCount(updateResult);
656
- let persisted = affected === 1;
657
- if (affected === undefined) {
658
- const [confirmed] = await db
659
- .select({
660
- content: schema.designFiles.content,
661
- updatedAt: schema.designFiles.updatedAt,
662
- contentOperationSource: schema.designFiles.contentOperationSource,
663
- contentOperationRevision: schema.designFiles.contentOperationRevision,
664
- contentOperationResultHash:
665
- schema.designFiles.contentOperationResultHash,
666
- })
667
- .from(schema.designFiles)
668
- .where(eq(schema.designFiles.id, args.file.id))
669
- .limit(1);
670
- persisted =
671
- confirmed?.content === authoritativeContent &&
672
- confirmed.updatedAt === updatedAt &&
673
- confirmed.contentOperationSource ===
674
- (identityOnly ? identityOnlyOperationSource : null) &&
675
- confirmed.contentOperationRevision ===
676
- (identityOnly ? identityOnlyOperationRevision : null) &&
677
- confirmed.contentOperationResultHash ===
678
- (identityOnly ? sourceContentHash(authoritativeContent) : null);
679
- }
703
+ const affected = affectedRowCount(updateResult);
704
+ let persisted = affected === 1;
705
+ if (affected === undefined) {
706
+ const [confirmed] = await tx
707
+ .select({
708
+ content: schema.designFiles.content,
709
+ updatedAt: schema.designFiles.updatedAt,
710
+ contentOperationSource: schema.designFiles.contentOperationSource,
711
+ contentOperationRevision:
712
+ schema.designFiles.contentOperationRevision,
713
+ contentOperationResultHash:
714
+ schema.designFiles.contentOperationResultHash,
715
+ })
716
+ .from(schema.designFiles)
717
+ .where(eq(schema.designFiles.id, args.file.id))
718
+ .limit(1);
719
+ persisted =
720
+ confirmed?.content === authoritativeContent &&
721
+ confirmed.updatedAt === updatedAt &&
722
+ confirmed.contentOperationSource ===
723
+ (identityOnly ? identityOnlyOperationSource : null) &&
724
+ confirmed.contentOperationRevision ===
725
+ (identityOnly ? identityOnlyOperationRevision : null) &&
726
+ confirmed.contentOperationResultHash ===
727
+ (identityOnly ? sourceContentHash(authoritativeContent) : null);
728
+ }
680
729
 
681
- if (!persisted) {
682
- const [winner] = await db
683
- .select({ content: schema.designFiles.content })
684
- .from(schema.designFiles)
685
- .where(eq(schema.designFiles.id, args.file.id))
686
- .limit(1);
687
- if (!identityOnly && winner && winner.content !== authoritativeContent) {
688
- await applyText(args.file.id, winner.content, "content", "agent");
730
+ if (!persisted) {
731
+ const [winner] = await tx
732
+ .select({ content: schema.designFiles.content })
733
+ .from(schema.designFiles)
734
+ .where(eq(schema.designFiles.id, args.file.id))
735
+ .limit(1);
736
+ if (
737
+ !identityOnly &&
738
+ winner &&
739
+ winner.content !== authoritativeContent
740
+ ) {
741
+ await applyText(args.file.id, winner.content, "content", "agent");
742
+ }
743
+ throw new SourceWorkspaceEditConflictError(
744
+ "Source file changed while it was being saved. Re-read the file and retry.",
745
+ );
689
746
  }
690
- throw new SourceWorkspaceEditConflictError(
691
- "Source file changed while it was being saved. Re-read the file and retry.",
692
- );
693
- }
694
747
 
695
- await db
696
- .update(schema.designs)
697
- .set({ updatedAt })
698
- .where(eq(schema.designs.id, args.designId));
748
+ await tx
749
+ .update(schema.designs)
750
+ .set({ updatedAt })
751
+ .where(eq(schema.designs.id, args.designId));
699
752
 
700
- return {
701
- versionHash: sourceContentHash(authoritativeContent),
702
- changed: authoritativeContent !== current.content,
703
- updatedAt,
704
- };
705
- });
753
+ return {
754
+ versionHash: sourceContentHash(authoritativeContent),
755
+ changed: authoritativeContent !== current.content,
756
+ updatedAt,
757
+ };
758
+ }),
759
+ );
706
760
  }
707
761
 
708
762
  export type InlineSourceBatchCollaborationFileStatus = {
@@ -727,6 +781,8 @@ export async function writeInlineSourceFilesBatch(args: {
727
781
  * Omit this for batches that intentionally touch only a subset of files.
728
782
  */
729
783
  expectedHtmlFileIds?: readonly string[];
784
+ /** Additional metadata mutation to commit with the source documents. */
785
+ afterFilesPersist?: (tx: DbExec, updatedAt: string) => Promise<void>;
730
786
  }): Promise<{
731
787
  files: Array<{
732
788
  id: string;
@@ -873,13 +929,9 @@ export async function writeInlineSourceFilesBatch(args: {
873
929
 
874
930
  await transaction(async (tx) => {
875
931
  if (args.expectedHtmlFileIds !== undefined) {
876
- // ponytail: table-wide lock; use per-design advisory locks across membership writers if contention grows.
877
- await tx.execute({
878
- sql: "LOCK TABLE design_files IN SHARE ROW EXCLUSIVE MODE",
879
- args: [],
880
- });
932
+ await lockDesignSourceMutation(tx, args.designId);
881
933
  const currentHtmlFiles = await tx.execute({
882
- sql: "SELECT id FROM design_files WHERE design_id = ? AND LOWER(file_type) = 'html' ORDER BY id",
934
+ sql: "SELECT id FROM design_files WHERE design_id = ? AND LOWER(file_type) = 'html' ORDER BY id FOR UPDATE",
883
935
  args: [args.designId],
884
936
  });
885
937
  const currentHtmlFileIds = currentHtmlFiles.rows.map((row) => {
@@ -940,6 +992,10 @@ export async function writeInlineSourceFilesBatch(args: {
940
992
  await lease.persist(tx, item.content);
941
993
  }
942
994
 
995
+ if (args.afterFilesPersist) {
996
+ await args.afterFilesPersist(tx, updatedAt);
997
+ }
998
+
943
999
  if (hasSqlChanges) {
944
1000
  const updatedDesign = await tx.execute({
945
1001
  sql: "UPDATE designs SET updated_at = ? WHERE id = ? RETURNING id",