@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/clips/app/root.tsx +59 -10
- package/corpus/templates/clips/changelog/2026-09-17-desktop-popover-closes-when-clicking-away.md +6 -0
- package/corpus/templates/clips/changelog/2026-09-17-self-hosted-extension-sign-in.md +6 -0
- package/corpus/templates/clips/chrome-extension/src/background.ts +45 -25
- package/corpus/templates/clips/chrome-extension/src/content-script.ts +43 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +55 -6
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +1 -0
- package/corpus/templates/design/actions/add-localhost-screens.ts +54 -22
- package/corpus/templates/design/actions/apply-visual-edit.ts +1 -0
- package/corpus/templates/design/actions/create-design-from-template.ts +50 -35
- package/corpus/templates/design/actions/create-design-version.ts +21 -0
- package/corpus/templates/design/actions/create-file.ts +35 -33
- package/corpus/templates/design/actions/delete-design.ts +29 -28
- package/corpus/templates/design/actions/delete-file.ts +154 -23
- package/corpus/templates/design/actions/generate-design.ts +28 -16
- package/corpus/templates/design/actions/get-component-details.ts +187 -33
- package/corpus/templates/design/actions/get-design-version.ts +9 -1
- package/corpus/templates/design/actions/go-to-main-component.ts +37 -35
- package/corpus/templates/design/actions/index-components.ts +241 -126
- package/corpus/templates/design/actions/migrate-board-objects-to-file.ts +12 -9
- package/corpus/templates/design/actions/present-design-variants.ts +68 -72
- package/corpus/templates/design/actions/rename-component.ts +294 -0
- package/corpus/templates/design/actions/rename-screen.ts +10 -10
- package/corpus/templates/design/actions/update-file.ts +327 -330
- package/corpus/templates/design/actions/update-screen-source.ts +2 -43
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +1 -0
- package/corpus/templates/design/app/components/design/code-workbench/CodeWorkbench.tsx +8 -1
- package/corpus/templates/design/app/components/design/code-workbench/explorer/FileTree.tsx +55 -33
- package/corpus/templates/design/app/components/design/code-workbench/workspace/create-providers.ts +2 -0
- package/corpus/templates/design/app/components/design/code-workbench/workspace/inline-provider.ts +4 -2
- package/corpus/templates/design/app/components/design/edit-panel/element-classification.ts +5 -1
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +57 -9
- package/corpus/templates/design/app/components/design/types.ts +1 -0
- package/corpus/templates/design/app/hooks/use-design-versions.ts +3 -2
- package/corpus/templates/design/app/i18n-breakpoints.ts +0 -110
- package/corpus/templates/design/app/i18n-data.ts +0 -10
- package/corpus/templates/design/app/pages/DesignEditor.tsx +250 -107
- package/corpus/templates/design/app/pages/design-editor/commands/apply-file-content-update.ts +2 -0
- package/corpus/templates/design/app/pages/design-editor/commands/delete-files.ts +69 -47
- package/corpus/templates/design/app/pages/design-editor/commands/redo.ts +8 -1
- package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +107 -2
- package/corpus/templates/design/app/pages/design-editor/commands/undo.ts +26 -1
- package/corpus/templates/design/app/pages/design-editor/editor-state.ts +63 -2
- package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +74 -22
- package/corpus/templates/design/app/pages/design-editor/source-publication.ts +14 -2
- package/corpus/templates/design/changelog/2026-09-16-design-components-retain-linked-identities-when-edited-and-d.md +6 -0
- package/corpus/templates/design/changelog/2026-09-17-screen-deletion-is-immediate-and-undoable-with-history-that-.md +6 -0
- package/corpus/templates/design/e2e/fixtures/design-component-react/index.html +98 -0
- package/corpus/templates/design/e2e/fixtures/design-component-react/inline-component.html +65 -0
- package/corpus/templates/design/server/lib/design-data-mutation.ts +9 -5
- package/corpus/templates/design/server/lib/design-snapshot.ts +28 -16
- package/corpus/templates/design/server/lib/design-versions.ts +208 -130
- package/corpus/templates/design/server/lib/fusion-screens.ts +46 -20
- package/corpus/templates/design/server/lib/import-design-files.ts +5 -1
- package/corpus/templates/design/server/lib/localhost-connection.ts +54 -0
- package/corpus/templates/design/server/source-workspace.ts +412 -356
- package/corpus/templates/design/shared/component-library.ts +36 -6
- package/corpus/templates/design/shared/component-model.ts +8 -2
- package/dist/automation/index.d.ts +1 -1
- package/dist/client/chat/agentkit-protocol.js +148 -34
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/server/core-routes-plugin.d.ts +1 -1
- package/package.json +4 -4
- 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,
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
"
|
|
414
|
+
"Source file changed since it was read. Re-read the file and retry.",
|
|
391
415
|
);
|
|
392
416
|
}
|
|
393
417
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
409
|
-
const
|
|
410
|
-
|
|
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:
|
|
516
|
+
versionHash: current.versionHash,
|
|
423
517
|
changed: false,
|
|
424
|
-
updatedAt: currentFile.updatedAt ??
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
748
|
+
await tx
|
|
749
|
+
.update(schema.designs)
|
|
750
|
+
.set({ updatedAt })
|
|
751
|
+
.where(eq(schema.designs.id, args.designId));
|
|
699
752
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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",
|