@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.
- 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 +107 -19
- package/corpus/templates/design/.generated/bridge/hit-test.generated.ts +1 -0
- package/corpus/templates/design/actions/add-localhost-screens.ts +54 -22
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +174 -14
- package/corpus/templates/design/actions/apply-visual-edit.ts +3 -1
- package/corpus/templates/design/actions/create-component.ts +173 -12
- 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 +272 -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/EditPanel.tsx +17 -1
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +228 -24
- package/corpus/templates/design/app/components/design/ResponsiveInteractBar.tsx +17 -1
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +151 -32
- package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +4 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +1 -1
- 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/component-section.tsx +273 -60
- package/corpus/templates/design/app/components/design/edit-panel/element-classification.ts +9 -2
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +57 -9
- package/corpus/templates/design/app/components/design/multi-screen/primitive-drop-target.ts +13 -0
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +14 -0
- package/corpus/templates/design/app/components/design/types.ts +9 -1
- 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 +669 -161
- package/corpus/templates/design/app/pages/design-editor/clone-and-pen-edit.ts +20 -0
- package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +2 -0
- package/corpus/templates/design/app/pages/design-editor/commands/apply-design-editor-command.ts +21 -3
- 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/create-component.ts +44 -3
- package/corpus/templates/design/app/pages/design-editor/commands/delete-files.ts +69 -47
- package/corpus/templates/design/app/pages/design-editor/commands/duplicate-screen.ts +157 -52
- package/corpus/templates/design/app/pages/design-editor/commands/duplicate-selection.ts +76 -1
- package/corpus/templates/design/app/pages/design-editor/commands/redo.ts +152 -44
- package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +131 -2
- package/corpus/templates/design/app/pages/design-editor/commands/screen-visual-duplicate-change.ts +5 -1
- package/corpus/templates/design/app/pages/design-editor/commands/undo.ts +26 -1
- package/corpus/templates/design/app/pages/design-editor/commands/visual-duplicate-change.ts +1 -0
- package/corpus/templates/design/app/pages/design-editor/editor-helpers.ts +58 -8
- package/corpus/templates/design/app/pages/design-editor/editor-state.ts +63 -2
- package/corpus/templates/design/app/pages/design-editor/history.ts +3 -0
- 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-16-fix-design-nested-layer-dragging-and-screen-duplication-pari.md +6 -0
- package/corpus/templates/design/changelog/2026-09-17-design-drag-and-drop-keeps-duplicates-cross-screen-moves-and.md +6 -0
- package/corpus/templates/design/changelog/2026-09-17-design-preserves-reusable-component-identity-and-props-acros.md +6 -0
- package/corpus/templates/design/changelog/2026-09-17-open-vector-paths-no-longer-show-fills-and-interact-dimensio.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/e2e/helpers.ts +6 -1
- 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 +12 -2
- package/corpus/templates/design/shared/local-jsx-visual-edit.ts +231 -5
- package/corpus/templates/design/shared/source-mode.ts +22 -0
- package/dist/automation/index.d.ts +1 -1
- package/dist/client/AssistantChat.d.ts +4 -1
- package/dist/client/AssistantChat.js +26 -3
- package/dist/client/chat/agentkit-protocol.js +148 -34
- package/dist/client/chat/message-components.js +7 -5
- package/dist/client/chat/tool-call-display.js +4 -3
- package/dist/client/conversation/AgentConversation.js +4 -2
- package/dist/client/tool-display.d.ts +9 -0
- package/dist/client/tool-display.js +15 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/localization/core-messages/ar-SA.js +2 -0
- package/dist/localization/core-messages/de-DE.js +2 -0
- package/dist/localization/core-messages/en-US.d.ts +2 -0
- package/dist/localization/core-messages/en-US.js +2 -0
- package/dist/localization/core-messages/es-ES.js +2 -0
- package/dist/localization/core-messages/fr-FR.js +2 -0
- package/dist/localization/core-messages/hi-IN.js +2 -0
- package/dist/localization/core-messages/ja-JP.js +2 -0
- package/dist/localization/core-messages/ko-KR.js +2 -0
- package/dist/localization/core-messages/pt-BR.js +2 -0
- package/dist/localization/core-messages/zh-CN.js +2 -0
- package/dist/localization/core-messages/zh-TW.js +2 -0
- package/dist/localization/core-messages.d.ts +2 -0
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +3 -3
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/core-routes-plugin.d.ts +1 -1
- package/package.json +3 -3
- 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
|
|
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,
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
schema.designFiles.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
431
|
-
|
|
369
|
+
sameOperationSource &&
|
|
370
|
+
requestedOperationRevision !== null &&
|
|
371
|
+
requestedOperationRevision <=
|
|
372
|
+
persistedFile.contentOperationRevision!
|
|
432
373
|
) {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
470
|
-
|
|
420
|
+
!skippedStaleOperation &&
|
|
421
|
+
expectedVersionHash !== undefined &&
|
|
422
|
+
content !== undefined
|
|
471
423
|
) {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
515
|
+
let updateResult: unknown;
|
|
521
516
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
//
|
|
525
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
//
|
|
614
|
-
//
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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,
|
|
607
|
+
await seedFromText(id, content);
|
|
635
608
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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 };
|