@agent-native/core-corpus 0.216.0 → 0.218.0

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 (196) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +61 -1
  3. package/corpus/templates/analytics/AGENTS.md +1 -1
  4. package/corpus/templates/analytics/CHANGELOG.md +3 -0
  5. package/corpus/templates/analytics/actions/get-session-replay-summary.ts +9 -65
  6. package/corpus/templates/analytics/actions/list-event-catalog.ts +1 -1
  7. package/corpus/templates/analytics/actions/list-session-outputs.ts +40 -0
  8. package/corpus/templates/analytics/actions/list-session-recordings.ts +17 -4
  9. package/corpus/templates/analytics/actions/view-screen.ts +29 -3
  10. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +38 -167
  11. package/corpus/templates/analytics/app/i18n/en-US.ts +27 -0
  12. package/corpus/templates/analytics/app/pages/sessions/EventCatalogPage.tsx +18 -1
  13. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +3 -1
  14. package/corpus/templates/analytics/app/pages/sessions/SessionOutputs.tsx +197 -0
  15. package/corpus/templates/analytics/app/pages/sessions/SessionsTriagePage.tsx +114 -4
  16. package/corpus/templates/analytics/changelog/2026-10-15-sessions-can-find-people-who-made-a-slides-deck-but-didn-t-p.md +6 -0
  17. package/corpus/templates/analytics/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  18. package/corpus/templates/analytics/changelog/2026-10-16-the-activation-funnel-counts-visitors-who-decline-analytics-.md +6 -0
  19. package/corpus/templates/analytics/server/db/schema.ts +67 -0
  20. package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +9 -0
  21. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +28 -22
  22. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +36 -1
  23. package/corpus/templates/analytics/server/lib/output-outcomes.ts +712 -0
  24. package/corpus/templates/analytics/server/lib/replay-origins.ts +33 -0
  25. package/corpus/templates/analytics/server/lib/session-event-index.ts +20 -0
  26. package/corpus/templates/analytics/server/lib/session-performance.ts +6 -40
  27. package/corpus/templates/analytics/server/lib/session-recording-associations.ts +30 -1
  28. package/corpus/templates/analytics/server/lib/session-replay-triage.ts +99 -0
  29. package/corpus/templates/analytics/server/lib/session-replay.ts +45 -31
  30. package/corpus/templates/analytics/server/lib/sessions-triage-lab.ts +8 -2
  31. package/corpus/templates/analytics/server/plugins/db.ts +46 -0
  32. package/corpus/templates/analytics/shared/output-use.ts +205 -0
  33. package/corpus/templates/analytics/shared/session-events.ts +6 -0
  34. package/corpus/templates/assets/CHANGELOG.md +6 -0
  35. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +51 -117
  36. package/corpus/templates/assets/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  37. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +34 -101
  38. package/corpus/templates/brain/app/routes/sources.tsx +1 -1
  39. package/corpus/templates/brain/vitest.config.ts +7 -0
  40. package/corpus/templates/calendar/CHANGELOG.md +12 -0
  41. package/corpus/templates/calendar/actions/create-event.ts +2 -4
  42. package/corpus/templates/calendar/actions/update-event.ts +35 -6
  43. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +1 -1
  44. package/corpus/templates/calendar/changelog/2026-10-16-booking-links-show-localized-visibility-badges.md +6 -0
  45. package/corpus/templates/calendar/changelog/2026-10-21-solo-events-avoid-notification-approval.md +6 -0
  46. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +62 -101
  47. package/corpus/templates/clips/CHANGELOG.md +7 -20
  48. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +20 -77
  49. package/corpus/templates/clips/app/components/player/share-dialog.tsx +63 -90
  50. package/corpus/templates/clips/app/components/sharing/share-test-support.tsx +180 -0
  51. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +5 -584
  52. package/corpus/templates/clips/changelog/2026-10-16-share-dialogs-make-it-easier-to-see-who-has-access-and-manag.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-10-21-share-panels-describe-general-access-in-a-sentence.md +6 -0
  54. package/corpus/templates/community/qr/package.json +1 -1
  55. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -3
  56. package/corpus/templates/content/CHANGELOG.md +10 -0
  57. package/corpus/templates/content/actions/_browser-document-save-attempt.ts +23 -1
  58. package/corpus/templates/content/actions/_builder-cms-blob-custody.ts +467 -0
  59. package/corpus/templates/content/actions/_builder-cms-execution-lease.ts +17 -0
  60. package/corpus/templates/content/actions/_builder-cms-intent-lookup.ts +5 -0
  61. package/corpus/templates/content/actions/_builder-cms-read-client.ts +881 -21
  62. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +28 -2
  63. package/corpus/templates/content/actions/_builder-cms-write-client.ts +109 -11
  64. package/corpus/templates/content/actions/_builder-docs-client.ts +1 -0
  65. package/corpus/templates/content/actions/_builder-private-blob.test-fixture.ts +90 -0
  66. package/corpus/templates/content/actions/_builder-source-execution-guard.ts +48 -0
  67. package/corpus/templates/content/actions/_content-save-outcomes.ts +5 -0
  68. package/corpus/templates/content/actions/_database-source-utils.ts +290 -8
  69. package/corpus/templates/content/actions/_database-utils.ts +93 -7
  70. package/corpus/templates/content/actions/_database-view-setup.ts +21 -2
  71. package/corpus/templates/content/actions/_delete-content-space.ts +8 -1
  72. package/corpus/templates/content/actions/_github-source-freshness.ts +30 -4
  73. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +23 -6
  74. package/corpus/templates/content/actions/cancel-prepared-builder-source-update.ts +110 -26
  75. package/corpus/templates/content/actions/change-content-database-source-role.ts +51 -7
  76. package/corpus/templates/content/actions/delete-document.ts +41 -3
  77. package/corpus/templates/content/actions/disconnect-content-database-source.ts +68 -2
  78. package/corpus/templates/content/actions/disconnect-local-folder-source.ts +33 -2
  79. package/corpus/templates/content/actions/execute-builder-source-execution.ts +900 -140
  80. package/corpus/templates/content/actions/get-document.ts +2 -0
  81. package/corpus/templates/content/actions/permanently-delete-document.ts +6 -0
  82. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +155 -13
  83. package/corpus/templates/content/actions/prepare-builder-source-review.ts +299 -73
  84. package/corpus/templates/content/actions/set-live-body-switch.ts +45 -0
  85. package/corpus/templates/content/actions/update-document.ts +139 -20
  86. package/corpus/templates/content/actions/validate-builder-source-execution.ts +135 -26
  87. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +119 -30
  88. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +56 -13
  89. package/corpus/templates/content/app/components/editor/SuggestionText.tsx +18 -3
  90. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +89 -22
  91. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +23 -0
  92. package/corpus/templates/content/app/components/editor/database/GitHubSourceStatusLine.tsx +27 -2
  93. package/corpus/templates/content/app/components/editor/database/ListItemLayout.tsx +32 -8
  94. package/corpus/templates/content/app/components/editor/database/ListItemRow.tsx +27 -2
  95. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +1 -0
  96. package/corpus/templates/content/app/components/editor/extensions/SuggestionHighlight.ts +383 -34
  97. package/corpus/templates/content/app/components/editor/suggestions/draft-session.ts +31 -0
  98. package/corpus/templates/content/app/components/editor/suggestions/presentation-rebase.ts +82 -4
  99. package/corpus/templates/content/app/global.css +31 -0
  100. package/corpus/templates/content/app/hooks/use-content-database.ts +40 -2
  101. package/corpus/templates/content/app/hooks/use-documents.ts +14 -0
  102. package/corpus/templates/content/app/i18n-data.ts +1 -0
  103. package/corpus/templates/content/changelog/2026-10-16-accepting-a-suggestion-on-a-page-that-changed-since-it-was-s.md +6 -0
  104. package/corpus/templates/content/changelog/2026-10-17-builder-content-avoids-retrying-a-write-when-builder-accepte.md +6 -0
  105. package/corpus/templates/content/changelog/2026-10-18-link-a-new-collection-to-a-github-folder-from-its-sources.md +1 -1
  106. package/corpus/templates/content/changelog/2026-10-20-builder-content-sync-keeps-read-and-write-status-accurate-wh.md +6 -0
  107. package/corpus/templates/content/changelog/2026-10-20-suggested-edits-that-add-remove-split-or-merge-paragraphs-li.md +6 -0
  108. package/corpus/templates/content/changelog/2026-10-21-list-rows-can-show-each-pages-first-lines.md +6 -0
  109. package/corpus/templates/content/changelog/2026-10-22-a-collection-linked-to-a-big-github-folder-fills-faster-and-p.md +6 -0
  110. package/corpus/templates/content/docs/product/capabilities/content.source.adapters.md +29 -0
  111. package/corpus/templates/content/parity/matrix.md +42 -42
  112. package/corpus/templates/content/parity/matrix.ts +3 -1
  113. package/corpus/templates/content/server/db/schema.ts +20 -0
  114. package/corpus/templates/content/server/lib/content-trash-purge.ts +15 -1
  115. package/corpus/templates/content/server/lib/live-body-writer.ts +138 -26
  116. package/corpus/templates/content/server/live-copy/binding.ts +31 -0
  117. package/corpus/templates/content/server/live-copy/bounded-task.d.ts +6 -0
  118. package/corpus/templates/content/server/live-copy/exec-db.ts +1 -0
  119. package/corpus/templates/content/server/live-copy/holds.ts +59 -0
  120. package/corpus/templates/content/server/live-copy/persist.ts +656 -0
  121. package/corpus/templates/content/server/live-copy/switch.ts +136 -0
  122. package/corpus/templates/content/server/live-copy/task-names.ts +4 -0
  123. package/corpus/templates/content/server/live-copy/tasks.ts +183 -0
  124. package/corpus/templates/content/server/live-copy/write-cause.ts +85 -0
  125. package/corpus/templates/content/server/live-copy/write.ts +85 -0
  126. package/corpus/templates/content/server/plugins/builder-private-payload-cleanup.ts +22 -0
  127. package/corpus/templates/content/server/plugins/collab.ts +3 -0
  128. package/corpus/templates/content/server/plugins/db.ts +28 -0
  129. package/corpus/templates/content/shared/api.ts +30 -1
  130. package/corpus/templates/content/shared/database-item-layout.ts +54 -22
  131. package/corpus/templates/content/shared/document-intent-merge.ts +16 -9
  132. package/corpus/templates/content/shared/og-description.ts +258 -36
  133. package/corpus/templates/content/shared/suggestion-diff.ts +23 -6
  134. package/corpus/templates/content/shared/suggestion-formatting.ts +44 -14
  135. package/corpus/templates/content/shared/suggestion-text.ts +20 -5
  136. package/corpus/templates/design/.agents/skills/design-templates/SKILL.md +5 -1
  137. package/corpus/templates/design/.generated/bridge/shader-runtime.generated.ts +25 -4
  138. package/corpus/templates/design/CHANGELOG.md +5 -0
  139. package/corpus/templates/design/actions/get-getting-started.ts +53 -0
  140. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +10 -0
  141. package/corpus/templates/design/app/components/design/bridge/native-shader-runtime.bridge.ts +132 -79
  142. package/corpus/templates/design/app/components/design/bridge/native-source-provider.ts +268 -130
  143. package/corpus/templates/design/app/components/design/bridge/native-worker-scene-host.ts +122 -42
  144. package/corpus/templates/design/app/components/design/bridge/shader-runtime.bridge.ts +41 -4
  145. package/corpus/templates/design/app/components/design/design-canvas/text-content-change-result.ts +46 -12
  146. package/corpus/templates/design/app/components/home/DesignHomeLibrary.tsx +15 -8
  147. package/corpus/templates/design/app/components/layout/GettingStarted.tsx +50 -0
  148. package/corpus/templates/design/app/components/layout/Sidebar.tsx +3 -0
  149. package/corpus/templates/design/app/components/templates/DesignTemplateGrid.tsx +163 -0
  150. package/corpus/templates/design/app/components/templates/DesignTemplateLibrary.tsx +65 -78
  151. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +6 -2
  152. package/corpus/templates/design/app/global.css +6 -0
  153. package/corpus/templates/design/app/i18n/en-US.ts +17 -0
  154. package/corpus/templates/design/app/lib/canvas-load-outcome.ts +147 -0
  155. package/corpus/templates/design/app/lib/canvas-outcome-tracking.ts +531 -0
  156. package/corpus/templates/design/app/lib/canvas-style-kind.ts +50 -0
  157. package/corpus/templates/design/app/pages/DesignSystems.tsx +1 -1
  158. package/corpus/templates/design/app/pages/Index.tsx +28 -67
  159. package/corpus/templates/design/app/pages/design-editor/commands/commit-visual-styles.ts +86 -29
  160. package/corpus/templates/design/app/pages/design-editor/commands/save-file-content.ts +38 -3
  161. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-core.ts +9 -0
  162. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-files-and-saving.ts +2 -0
  163. package/corpus/templates/design/changelog/2026-10-16-design-pages-use-localized-visibility-badges-and-a-shared-ow.md +6 -0
  164. package/corpus/templates/design/changelog/2026-10-21-a-getting-started-checklist-at-the-bottom-of-the-sidebar-tra.md +6 -0
  165. package/corpus/templates/design/changelog/2026-10-21-templates-now-open-a-read-only-preview-from-the-card-and-the.md +6 -0
  166. package/corpus/templates/design/changelog/2026-10-22-collapsed-shader-layers-no-longer-interrupt-other-shaders-on.md +6 -0
  167. package/corpus/templates/design/changelog/2026-10-22-shader-fills-keep-rendering-when-other-screen-content-cannot.md +6 -0
  168. package/corpus/templates/design/scripts/design-export-validation/export-baseline.json +24 -0
  169. package/corpus/templates/design/server/lib/getting-started.ts +267 -0
  170. package/corpus/templates/design/server/plugins/getting-started.ts +5 -0
  171. package/corpus/templates/design/shared/design-template-presets/analytics-dashboard.ts +127 -0
  172. package/corpus/templates/design/shared/design-template-presets/design-studio.ts +98 -0
  173. package/corpus/templates/design/shared/design-template-presets/messaging-app.ts +101 -0
  174. package/corpus/templates/design/shared/design-template-presets/project-tasks.ts +120 -0
  175. package/corpus/templates/design/shared/design-template-presets.ts +8 -0
  176. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  177. package/corpus/templates/dispatch/app/i18n-data.ts +75 -0
  178. package/corpus/templates/dispatch/changelog/2026-10-21-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  179. package/corpus/templates/factory/app/components/layout/Sidebar.tsx +34 -100
  180. package/corpus/templates/forms/app/pages/FormsListPage.tsx +1 -1
  181. package/corpus/templates/forms/server/plugins/agent-chat.ts +1 -1
  182. package/corpus/templates/plan/CHANGELOG.md +6 -0
  183. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +34 -109
  184. package/corpus/templates/plan/changelog/2026-10-16-owned-and-shared-chats-appear-in-separate-history-sections.md +6 -0
  185. package/corpus/templates/slides/CHANGELOG.md +7 -0
  186. package/corpus/templates/slides/actions/delete-deck.ts +7 -0
  187. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +1 -1
  188. package/corpus/templates/slides/app/components/design-system/DesignSystemCard.tsx +1 -1
  189. package/corpus/templates/slides/app/pages/Index.tsx +7 -2
  190. package/corpus/templates/slides/changelog/2026-10-16-deck-lists-use-the-shared-ownership-filter-and-visibility-ba.md +6 -0
  191. package/corpus/templates/slides/changelog/2026-10-21-connected-deck-deletion-requires-approval.md +6 -0
  192. package/corpus/templates/slides/scripts/edit-fidelity/client-action.ts +61 -0
  193. package/corpus/templates/slides/scripts/edit-fidelity/run.ts +10 -0
  194. package/package.json +1 -1
  195. package/corpus/templates/chat/app/lib/sidebar-thread-state.ts +0 -29
  196. package/corpus/templates/slides/app/components/deck/DeckFilterMenu.tsx +0 -83
@@ -20,6 +20,8 @@ export const documents = table("documents", {
20
20
  bodyRevision: integer("body_revision").notNull().default(0),
21
21
  collabBodyRevision: integer("collab_body_revision"),
22
22
  liveBodySwitchedAt: text("live_body_switched_at"),
23
+ // `_collab_docs.version` the stored body was last derived from.
24
+ liveCopyVersion: integer("live_copy_version"),
23
25
  description: text("description").notNull().default(""),
24
26
  icon: text("icon"),
25
27
  position: integer("position").notNull().default(0),
@@ -802,6 +804,24 @@ export const contentDatabaseSourceExecutions = table(
802
804
  },
803
805
  );
804
806
 
807
+ export const contentDatabaseBuilderBlobCleanup = table(
808
+ "content_database_builder_blob_cleanup",
809
+ {
810
+ blobReference: text("blob_reference").primaryKey(),
811
+ createdAt: text("created_at").notNull().default(now()),
812
+ attemptCount: integer("attempt_count").notNull().default(0),
813
+ nextAttemptAt: text("next_attempt_at")
814
+ .notNull()
815
+ .default("1970-01-01T00:00:00.000Z"),
816
+ },
817
+ (cleanup) => [
818
+ index("content_database_builder_blob_cleanup_due_idx").on(
819
+ cleanup.nextAttemptAt,
820
+ cleanup.createdAt,
821
+ ),
822
+ ],
823
+ );
824
+
805
825
  export const contentDatabaseSourceExecutionClaims = table(
806
826
  "content_database_source_execution_claims",
807
827
  {
@@ -26,6 +26,7 @@ import {
26
26
  } from "drizzle-orm";
27
27
  import { alias } from "drizzle-orm/pg-core";
28
28
 
29
+ import { cleanupBuilderPrivatePayload } from "../../actions/_builder-cms-blob-custody.js";
29
30
  import { contentTrashPredicates } from "../../actions/_content-trash-query.js";
30
31
  import {
31
32
  deleteTrashedDocumentSubtree,
@@ -305,6 +306,7 @@ export async function processContentTrashPurge(operationId: string) {
305
306
  }
306
307
 
307
308
  const units = unitToDelete ? [unitToDelete] : [];
309
+ const deletedBlobReferences = new Set<string>();
308
310
  let deletedDelta = 0;
309
311
  for (const unit of units) {
310
312
  const frozen = allPending.filter(
@@ -316,6 +318,7 @@ export async function processContentTrashPurge(operationId: string) {
316
318
  transactionDb,
317
319
  unit.rootDocumentId,
318
320
  unit.ownerEmail,
321
+ deletedBlobReferences,
319
322
  frozen,
320
323
  frozen[0]?.expectedScopeFingerprint,
321
324
  );
@@ -372,12 +375,23 @@ export async function processContentTrashPurge(operationId: string) {
372
375
  });
373
376
  if (!countReceipt) throw new ContentTrashPurgeLeaseLostError();
374
377
  }
375
- return { blockedDelta, deletedDelta, unitCount: units.length };
378
+ return {
379
+ blockedDelta,
380
+ deletedDelta,
381
+ deletedBlobReferences,
382
+ unitCount: units.length,
383
+ };
376
384
  },
377
385
  { isolationLevel: "serializable" },
378
386
  );
379
387
  claimed.blockedCount += batchResult.blockedDelta;
380
388
  claimed.deletedCount += batchResult.deletedDelta;
389
+ for (const reference of batchResult.deletedBlobReferences) {
390
+ await cleanupBuilderPrivatePayload(
391
+ reference,
392
+ "deleted document source",
393
+ );
394
+ }
381
395
  if (batchResult.unitCount === 0) break;
382
396
  }
383
397
  } catch (error) {
@@ -3,7 +3,7 @@ import {
3
3
  evaluateFeatureFlagRules,
4
4
  getFeatureFlagRulesStrict,
5
5
  } from "@agent-native/core/feature-flags";
6
- import { eq } from "drizzle-orm";
6
+ import { eq, sql } from "drizzle-orm";
7
7
 
8
8
  import {
9
9
  LIVE_BODY_SHADOW_FLAG,
@@ -19,11 +19,14 @@ import { schema } from "../db/index.js";
19
19
  // The flags are evaluated for the page, not the caller. Two collaborators with
20
20
  // different flags would otherwise write one body down two paths at once.
21
21
  //
22
- // `collab_body_revision == body_revision` is what makes the kill switch safe.
23
- // While the flags are off, a switched page takes legacy SQL writes that its
24
- // live copy never sees; once the flags come back, deriving from that stale
25
- // copy would overwrite them. A legacy write bumps only `body_revision`, so the
26
- // page stays on the legacy path until it is converted again.
22
+ // Two markers make the kill switch safe, one per side. While the flags are
23
+ // off, a switched page takes legacy SQL writes its live copy never sees, and
24
+ // editor updates that commit to Yjs on main's path, where nothing derives the
25
+ // stored body. A legacy write bumps only `body_revision`, so
26
+ // `collab_body_revision` falls behind it; a Yjs commit bumps only
27
+ // `_collab_docs.version`, so `live_copy_version` falls behind that. Either
28
+ // keeps the page on the legacy path until it is converted again; deriving
29
+ // from a copy out of step with the stored body would overwrite one side.
27
30
 
28
31
  export type LiveBodyWritePath = "live_copy" | "legacy";
29
32
 
@@ -31,6 +34,7 @@ export type LiveBodyLegacyReason =
31
34
  | "no_page"
32
35
  | "not_switched"
33
36
  | "live_copy_behind"
37
+ | "stored_body_behind"
34
38
  | "flag_off";
35
39
 
36
40
  export type LiveBodyWriterGate =
@@ -39,55 +43,144 @@ export type LiveBodyWriterGate =
39
43
 
40
44
  export type LiveBodyWriterPageRow = Pick<
41
45
  typeof schema.documents.$inferSelect,
46
+ | "id"
42
47
  | "liveBodySwitchedAt"
43
48
  | "bodyRevision"
44
49
  | "collabBodyRevision"
50
+ | "liveCopyVersion"
45
51
  | "ownerEmail"
46
52
  | "orgId"
47
53
  >;
48
54
 
49
55
  export const liveBodyWriterPageColumns = {
56
+ id: schema.documents.id,
50
57
  liveBodySwitchedAt: schema.documents.liveBodySwitchedAt,
51
58
  bodyRevision: schema.documents.bodyRevision,
52
59
  collabBodyRevision: schema.documents.collabBodyRevision,
60
+ liveCopyVersion: schema.documents.liveCopyVersion,
53
61
  ownerEmail: schema.documents.ownerEmail,
54
62
  orgId: schema.documents.orgId,
55
63
  };
56
64
 
57
65
  /**
58
- * Decides the write path for a page row the caller already read, for example
59
- * under the row lock its write holds; pass that transaction so the flags are
60
- * read in it rather than from the request's settings snapshot. Unreadable or
61
- * malformed flag state throws: reading it as "off" would send a switched
62
- * page's writes down the legacy path.
66
+ * Decides the write path for a page row the caller read under its row lock;
67
+ * pass that transaction so the flags and the stored live copy's version are
68
+ * read in it rather than from the request's settings snapshot. A caller
69
+ * without the lock uses `liveBodyWriterGate`. Unreadable or malformed flag
70
+ * state throws: reading it as "off" would send a switched page's writes down
71
+ * the legacy path.
63
72
  */
64
73
  export async function liveBodyWriterGateForRow(
65
74
  row: LiveBodyWriterPageRow | null | undefined,
66
75
  options: { transaction?: DbExec } = {},
76
+ ): Promise<LiveBodyWriterGate> {
77
+ return gateForMarkers(
78
+ row,
79
+ async (documentId) => {
80
+ const { rows } = await (options.transaction ?? getDbExec()).execute({
81
+ sql: "SELECT version FROM _collab_docs WHERE doc_id = ?",
82
+ args: [documentId],
83
+ });
84
+ return (rows[0] as { version?: unknown } | undefined)?.version;
85
+ },
86
+ options,
87
+ );
88
+ }
89
+
90
+ async function gateForMarkers(
91
+ row: LiveBodyWriterPageRow | null | undefined,
92
+ readCollabVersion: (documentId: string) => Promise<unknown>,
93
+ options: { transaction?: DbExec },
67
94
  ): Promise<LiveBodyWriterGate> {
68
95
  if (!row) return { writePath: "legacy", reason: "no_page" };
69
96
  if (!row.liveBodySwitchedAt)
70
97
  return { writePath: "legacy", reason: "not_switched" };
71
98
  if (row.collabBodyRevision !== row.bodyRevision)
72
99
  return { writePath: "legacy", reason: "live_copy_behind" };
73
- const scope = { userEmail: row.ownerEmail, orgId: row.orgId };
74
- const [writer, shadow] = await Promise.all(
75
- [LIVE_BODY_WRITER_FLAG, LIVE_BODY_SHADOW_FLAG].map(async (flag) =>
76
- evaluateFeatureFlagRules(
77
- flag.key,
78
- await getFeatureFlagRulesStrict(flag.key, {
79
- ...scope,
80
- transaction: options.transaction,
81
- }),
82
- scope,
83
- ),
84
- ),
100
+ // The stored version, not the one an edit was built on: a peer's commit
101
+ // moves both markers, and core's fence retries the overtaken edit.
102
+ const collabVersion = parseCollabVersion(
103
+ row.id,
104
+ await readCollabVersion(row.id),
85
105
  );
86
- return writer && shadow
106
+ if (collabVersion === null || row.liveCopyVersion !== collabVersion)
107
+ return { writePath: "legacy", reason: "stored_body_behind" };
108
+ return (await liveBodyWriterFlagsOn(row, options))
87
109
  ? { writePath: "live_copy" }
88
110
  : { writePath: "legacy", reason: "flag_off" };
89
111
  }
90
112
 
113
+ // Core's version for the page's collab doc; null when it has none.
114
+ function parseCollabVersion(documentId: string, version: unknown) {
115
+ if (version === undefined || version === null) return null;
116
+ const parsed = Number(version);
117
+ if (!Number.isSafeInteger(parsed)) {
118
+ throw new Error(
119
+ `Page ${documentId}'s collab doc has an unreadable version (${String(version)}).`,
120
+ );
121
+ }
122
+ return parsed;
123
+ }
124
+
125
+ /** Both flags, for the page's owner and org; throws like the gate. */
126
+ export async function liveBodyWriterFlagsOn(
127
+ row: Pick<LiveBodyWriterPageRow, "ownerEmail" | "orgId">,
128
+ options: { transaction?: DbExec } = {},
129
+ ): Promise<boolean> {
130
+ const [writer, shadow] = await Promise.all(
131
+ [LIVE_BODY_WRITER_FLAG, LIVE_BODY_SHADOW_FLAG].map((flag) =>
132
+ flagOnForPage(flag.key, row, options.transaction),
133
+ ),
134
+ );
135
+ return writer && shadow;
136
+ }
137
+
138
+ async function flagOnForPage(
139
+ key: string,
140
+ row: Pick<LiveBodyWriterPageRow, "ownerEmail" | "orgId">,
141
+ transaction: DbExec | undefined,
142
+ ): Promise<boolean> {
143
+ const scope = { userEmail: row.ownerEmail, orgId: row.orgId };
144
+ const rules = await getFeatureFlagRulesStrict(key, { ...scope, transaction });
145
+ if (evaluateFeatureFlagRules(key, rules, scope)) return true;
146
+ if (row.orgId) return false;
147
+ // `enable-for-current-user` stores an enablement in the org its manager had
148
+ // active, but a personal page has no org, so look for the owner's own
149
+ // listing in each of their orgs. Only that listing counts: an org's rollout
150
+ // covers the org's pages, not its members' personal ones.
151
+ const owner = row.ownerEmail.trim().toLowerCase();
152
+ for (const orgId of await ownerOrgIds(owner, transaction)) {
153
+ const orgRules = await getFeatureFlagRulesStrict(key, {
154
+ orgId,
155
+ transaction,
156
+ });
157
+ if (orgRules.mode !== "off" && orgRules.emails.includes(owner)) return true;
158
+ }
159
+ return false;
160
+ }
161
+
162
+ // Read here rather than through core's membership cache, which answers for the
163
+ // request's caller and turns an unreadable table into "no orgs".
164
+ async function ownerOrgIds(
165
+ owner: string,
166
+ transaction: DbExec | undefined,
167
+ ): Promise<string[]> {
168
+ const exec = transaction ?? getDbExec();
169
+ const relation = await exec.execute({
170
+ sql: "SELECT to_regclass('org_members') AS relation",
171
+ args: [],
172
+ });
173
+ // A deployment without organizations has no memberships.
174
+ if (!relation.rows[0]?.relation) return [];
175
+ const { rows } = await exec.execute({
176
+ sql: `SELECT org_id FROM org_members
177
+ WHERE LOWER(email) = ? AND federation_removal_pending_at IS NULL
178
+ ORDER BY org_id`,
179
+ args: [owner],
180
+ });
181
+ return rows.map((row) => String((row as { org_id: unknown }).org_id));
182
+ }
183
+
91
184
  // Request reads retain settings initialization and the request snapshot. A
92
185
  // transaction context is explicit: an executor's shape does not identify a
93
186
  // transaction. Ordinary database adapters enter through core's withDbExec.
@@ -173,6 +266,7 @@ function readContext(options: LiveBodyWriterReadOptions): {
173
266
  throw new LiveBodyWriterContextError();
174
267
  }
175
268
 
269
+ /** The write path for a page, read without its row lock. */
176
270
  export async function liveBodyWriterGate(
177
271
  docId: string,
178
272
  options: LiveBodyWriterReadOptions = {},
@@ -184,8 +278,26 @@ export async function liveBodyWriterGate(
184
278
  .from(schema.documents)
185
279
  .where(eq(schema.documents.id, docId))
186
280
  .limit(1);
187
- return liveBodyWriterGateForRow(
188
- row,
281
+ // Most pages stop here, before `_collab_docs`, which a deployment creates
282
+ // with its first collab doc.
283
+ if (!row) return { writePath: "legacy", reason: "no_page" };
284
+ if (!row.liveBodySwitchedAt)
285
+ return { writePath: "legacy", reason: "not_switched" };
286
+ // A live-copy commit moves the page's markers and its collab doc's version
287
+ // together, so read them in one statement: two reads could straddle a
288
+ // commit and see the page behind its own live copy.
289
+ // guard:allow-unscoped — callers check access to the page first, and the gate returns only its write path.
290
+ const [markers] = await db
291
+ .select({
292
+ ...liveBodyWriterPageColumns,
293
+ collabVersion: sql<unknown>`(SELECT collab.version FROM _collab_docs AS collab WHERE collab.doc_id = documents.id)`,
294
+ })
295
+ .from(schema.documents)
296
+ .where(eq(schema.documents.id, docId))
297
+ .limit(1);
298
+ return gateForMarkers(
299
+ markers,
300
+ async () => markers?.collabVersion,
189
301
  context.kind === "transaction" ? { transaction: context.executor } : {},
190
302
  );
191
303
  }
@@ -0,0 +1,31 @@
1
+ import {
2
+ createBoundedTaskRunner,
3
+ defineLiveCopy,
4
+ } from "@agent-native/core/collab";
5
+
6
+ import { LIVE_BODY_FIELD } from "../../shared/live-body.js";
7
+ import { liveBodyWriterGate } from "../lib/live-body-writer.js";
8
+ import { persistContentLiveCopy } from "./persist.js";
9
+ import source from "./tasks.ts?bounded-task";
10
+ import { liveBodyConversionFor } from "./write-cause.js";
11
+
12
+ /** Edits `tasks.ts` defines, by name. */
13
+ export const CONTENT_LIVE_COPY_EDITS = {
14
+ replaceBody: "replace-body",
15
+ } as const;
16
+
17
+ /**
18
+ * Content's page bodies as live copies. A page is covered while the D2 gate
19
+ * says `live_copy`, and while it is being switched. Eligibility uses the
20
+ * request's flag snapshot; persist rechecks flags in its locked transaction.
21
+ */
22
+ export const contentLiveCopy = defineLiveCopy({
23
+ name: "content-body",
24
+ appliesTo: async (documentId) => {
25
+ if (liveBodyConversionFor(documentId) !== undefined) return true;
26
+ return (await liveBodyWriterGate(documentId)).writePath === "live_copy";
27
+ },
28
+ runner: createBoundedTaskRunner({ source }),
29
+ field: LIVE_BODY_FIELD,
30
+ persist: persistContentLiveCopy,
31
+ });
@@ -0,0 +1,6 @@
1
+ // `import source from "./tasks.ts?bounded-task"` is bundled at build time by
2
+ // core's bounded-task plugin into a source the worker runner evaluates.
3
+ declare module "*?bounded-task" {
4
+ const source: import("@agent-native/core/collab").BoundedTaskSource;
5
+ export default source;
6
+ }
@@ -0,0 +1 @@
1
+ export { drizzleOverExec } from "../db/exec.js";
@@ -0,0 +1,59 @@
1
+ import {
2
+ BoundedTaskError,
3
+ createBoundedTaskRunner,
4
+ type BoundedTaskErrorCode,
5
+ } from "@agent-native/core/collab";
6
+
7
+ import { CONTENT_HOLDS_BODY_TASK, type HoldsBodyResult } from "./task-names.js";
8
+ import source from "./tasks.ts?bounded-task";
9
+
10
+ // Its own worker: a save's comparison runs under the page lock, and on the
11
+ // live copy's worker a slow one would hold up every page's collab updates.
12
+ const runner = createBoundedTaskRunner({ source });
13
+
14
+ // Limits a body can hit. Anything else the worker reports is a bug here.
15
+ const UNCHECKED: ReadonlySet<BoundedTaskErrorCode> = new Set([
16
+ "unavailable",
17
+ "queue_full",
18
+ "queue_timeout",
19
+ "input_too_large",
20
+ "deadline_exceeded",
21
+ "memory_exceeded",
22
+ "worker_crashed",
23
+ ]);
24
+
25
+ const RESULTS: ReadonlySet<unknown> = new Set<HoldsBodyResult>([
26
+ "held",
27
+ "unheld",
28
+ "noncanonical",
29
+ ]);
30
+
31
+ /**
32
+ * Whether a switched page's stored body holds every change a browser save
33
+ * made to the body it was authored on, with any other changes clear of them
34
+ * (`bodyHoldsChanges`). The save's text then reached the live copy over
35
+ * collab, and only a peer's commits differ. `noncanonical` when the save's
36
+ * body is not its own serialization, and `unchecked` when the comparison hit
37
+ * the worker's limits.
38
+ */
39
+ export async function liveCopyHoldsSave(args: {
40
+ base: string;
41
+ stored: string;
42
+ candidate: string;
43
+ }): Promise<HoldsBodyResult | "unchecked"> {
44
+ let result: unknown;
45
+ try {
46
+ result = await runner.run<unknown>(CONTENT_HOLDS_BODY_TASK, args);
47
+ } catch (error) {
48
+ if (error instanceof BoundedTaskError && UNCHECKED.has(error.code)) {
49
+ return "unchecked";
50
+ }
51
+ throw error;
52
+ }
53
+ if (!RESULTS.has(result)) {
54
+ throw new TypeError(
55
+ `${CONTENT_HOLDS_BODY_TASK} answered ${JSON.stringify(result)}`,
56
+ );
57
+ }
58
+ return result as HoldsBodyResult;
59
+ }