@agent-native/core 0.136.4 → 0.137.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 (170) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +53 -0
  3. package/corpus/templates/clips/actions/import-loom-recording.ts +7 -0
  4. package/corpus/templates/clips/actions/lib/loom-import-job.ts +24 -4
  5. package/corpus/templates/clips/app/components/import-menu.tsx +109 -0
  6. package/corpus/templates/clips/app/components/library/library-grid.tsx +8 -18
  7. package/corpus/templates/clips/app/components/library/library-layout.tsx +14 -50
  8. package/corpus/templates/clips/app/components/library/sort-menu.tsx +20 -6
  9. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +10 -34
  10. package/corpus/templates/clips/app/routes/_app.library._index.tsx +0 -2
  11. package/corpus/templates/clips/changelog/2026-08-03-restart-during-a-recording-now-immediately-starts-a-fresh-ta.md +6 -0
  12. package/corpus/templates/clips/changelog/2026-08-04-cleaner-library-controls-with-an-icon-only-sort-button.md +6 -0
  13. package/corpus/templates/clips/changelog/2026-08-04-upload-and-loom-imports-now-live-under-one-import-menu.md +6 -0
  14. package/corpus/templates/clips/desktop/src/app.tsx +81 -30
  15. package/corpus/templates/clips/desktop/src/lib/recorder.ts +436 -158
  16. package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +14 -1
  17. package/corpus/templates/clips/server/plugins/auth.ts +9 -0
  18. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +13 -0
  19. package/corpus/templates/content/actions/_database-utils.ts +6 -0
  20. package/corpus/templates/content/actions/bind-content-database-source-field.ts +273 -97
  21. package/corpus/templates/content/actions/change-content-database-source-role.ts +65 -29
  22. package/corpus/templates/content/actions/configure-document-property.ts +113 -32
  23. package/corpus/templates/content/actions/delete-content-database.ts +5 -7
  24. package/corpus/templates/content/actions/delete-document-property.ts +109 -62
  25. package/corpus/templates/content/actions/delete-document.ts +178 -36
  26. package/corpus/templates/content/actions/duplicate-database-item.ts +27 -0
  27. package/corpus/templates/content/actions/duplicate-database-items.ts +31 -1
  28. package/corpus/templates/content/actions/permanently-delete-document.ts +4 -6
  29. package/corpus/templates/content/actions/remove-database-items.ts +13 -0
  30. package/corpus/templates/content/actions/set-document-property.ts +69 -1
  31. package/corpus/templates/content/actions/upsert-database-item-by-key.ts +814 -0
  32. package/corpus/templates/content/parity/matrix.md +1 -1
  33. package/corpus/templates/content/parity/matrix.ts +1 -0
  34. package/corpus/templates/content/server/db/schema.ts +27 -0
  35. package/corpus/templates/content/server/plugins/db.ts +24 -0
  36. package/corpus/templates/design/app/components/design/BreakpointBar.tsx +122 -114
  37. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +2 -2
  38. package/corpus/templates/design/app/components/design/EditPanel.tsx +68 -34
  39. package/corpus/templates/design/app/components/design/KeyboardShortcutsPanel.tsx +78 -4
  40. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +8 -0
  41. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +5 -2
  42. package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +10 -9
  43. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +31 -69
  44. package/corpus/templates/design/app/components/design/inspector/design-icons.tsx +80 -106
  45. package/corpus/templates/design/app/components/design/multi-screen/types.ts +4 -0
  46. package/corpus/templates/design/app/hooks/use-editor-preferences.ts +75 -0
  47. package/corpus/templates/design/app/i18n-data.ts +8 -0
  48. package/corpus/templates/design/app/i18n-keyboard-shortcuts.ts +61 -0
  49. package/corpus/templates/design/app/pages/design-editor/editor-preferences.ts +98 -0
  50. package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +491 -0
  51. package/corpus/templates/design/app/pages/design-editor/paste-placement.ts +133 -0
  52. package/corpus/templates/design/app/pages/design-editor/png-export-render.ts +8 -0
  53. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +5 -12
  54. package/corpus/templates/design/changelog/2026-08-03-arrow-keys-now-reorder-a-layer-inside-an-auto-layout-frame.md +6 -0
  55. package/corpus/templates/design/changelog/2026-08-03-auto-layout-flow-icons-show-the-direction-at-a-glance-instea.md +6 -0
  56. package/corpus/templates/design/changelog/2026-08-03-auto-layout-icons-share-one-outlined-square-style-so-flow-ga.md +6 -0
  57. package/corpus/templates/design/changelog/2026-08-03-export-has-a-named-preview-section-you-can-expand-replacing-.md +6 -0
  58. package/corpus/templates/design/changelog/2026-08-03-export-preview-shows-the-selected-element-instead-of-the-who.md +6 -0
  59. package/corpus/templates/design/changelog/2026-08-03-inspector-sections-show-an-expand-indicator-so-it-is-clear-w.md +6 -0
  60. package/corpus/templates/design/changelog/2026-08-03-paste-lands-inside-a-selected-frame-instead-of-beside-it.md +6 -0
  61. package/corpus/templates/design/changelog/2026-08-03-the-add-breakpoint-button-sits-outside-the-breakpoint-select.md +6 -0
  62. package/corpus/templates/design/changelog/2026-08-03-the-agent-s-follow-up-questions-keep-space-below-the-continu.md +6 -0
  63. package/corpus/templates/design/changelog/2026-08-03-the-agent-s-follow-up-questions-share-the-chat-panel-s-backg.md +6 -0
  64. package/corpus/templates/design/changelog/2026-08-03-the-export-panel-no-longer-stacks-a-duplicate-export-block-o.md +6 -0
  65. package/corpus/templates/design/changelog/2026-08-03-the-text-tool-uses-a-t-icon-matching-the-letter-it-inserts.md +6 -0
  66. package/corpus/templates/factory/.env.example +17 -0
  67. package/corpus/templates/factory/AGENTS.md +27 -17
  68. package/corpus/templates/factory/README.md +19 -9
  69. package/corpus/templates/factory/actions/get-slack-feedback-context.ts +90 -0
  70. package/corpus/templates/factory/actions/get-triage-config.ts +14 -3
  71. package/corpus/templates/factory/actions/govern-agent-native-pull-request.ts +386 -0
  72. package/corpus/templates/factory/actions/list-factory-automations.ts +53 -0
  73. package/corpus/templates/factory/actions/list-triage-items.ts +7 -2
  74. package/corpus/templates/factory/actions/poll-github-sources.ts +203 -0
  75. package/corpus/templates/factory/actions/poll-sentry-errors.ts +161 -0
  76. package/corpus/templates/factory/actions/poll-slack-channel.ts +4 -2
  77. package/corpus/templates/factory/actions/run-factory-automation.ts +42 -0
  78. package/corpus/templates/factory/actions/save-factory-automation.ts +69 -0
  79. package/corpus/templates/factory/actions/save-triage-config.ts +28 -2
  80. package/corpus/templates/factory/actions/start-builder-for-item.ts +411 -0
  81. package/corpus/templates/factory/app/components/factory/FactoryCanvas.tsx +14 -3
  82. package/corpus/templates/factory/app/components/ui/checkbox.tsx +1 -0
  83. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +7 -0
  84. package/corpus/templates/factory/app/i18n/en-US.ts +35 -1
  85. package/corpus/templates/factory/app/routes/factory.tsx +462 -9
  86. package/corpus/templates/factory/changelog/2026-08-04-added-organization-owned-factory-automations-for-slack-bug-t.md +6 -0
  87. package/corpus/templates/factory/changelog/2026-08-04-automation-editor-controls-and-model-picker.md +6 -0
  88. package/corpus/templates/factory/netlify.toml +2 -2
  89. package/corpus/templates/factory/server/connectors/credentials.ts +6 -5
  90. package/corpus/templates/factory/server/connectors/slack.ts +133 -5
  91. package/corpus/templates/factory/server/db/schema.ts +6 -0
  92. package/corpus/templates/factory/server/lib/require-factory-automation.ts +65 -0
  93. package/corpus/templates/factory/server/plugins/agent-chat.ts +29 -11
  94. package/corpus/templates/factory/server/plugins/auth.ts +1 -0
  95. package/corpus/templates/factory/server/plugins/factory-migrations.ts +12 -0
  96. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +218 -67
  97. package/corpus/templates/factory/server/triage/builder-executor.ts +4 -0
  98. package/corpus/templates/factory/server/triage/contracts.ts +14 -1
  99. package/corpus/templates/factory/server/triage/github-client.ts +404 -0
  100. package/corpus/templates/factory/server/triage/metadata.ts +44 -0
  101. package/corpus/templates/factory/server/triage/pr-babysit.ts +5 -5
  102. package/corpus/templates/factory/server/triage/pr-policy.ts +144 -0
  103. package/corpus/templates/factory/server/triage/sentry-client.ts +180 -0
  104. package/corpus/templates/factory/server/triage/slack-client.ts +43 -0
  105. package/corpus/templates/factory/server/triage/slack-poller.ts +9 -2
  106. package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +27 -0
  107. package/corpus/templates/plan/.agents/skills/visual-plan/references/canvas.md +14 -4
  108. package/corpus/templates/plan/.agents/skills/visual-plan/references/wireframe.md +7 -0
  109. package/corpus/templates/plan/.agents/skills/visual-recap/references/wireframe.md +7 -0
  110. package/corpus/templates/plan/actions/create-plan-design.ts +8 -6
  111. package/corpus/templates/plan/actions/create-prototype-plan.ts +8 -6
  112. package/corpus/templates/plan/actions/create-ui-plan.ts +7 -7
  113. package/corpus/templates/plan/actions/get-plan-blocks.ts +7 -1
  114. package/corpus/templates/plan/actions/validate-plan-input.ts +39 -0
  115. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +167 -11
  116. package/corpus/templates/plan/app/i18n/en-US.ts +1 -0
  117. package/corpus/templates/plan/changelog/2026-08-04-canvas-zoom-controls-now-explain-the-command-ctrl-plus-scrol.md +6 -0
  118. package/corpus/templates/plan/changelog/2026-08-04-pinch-to-zoom-is-now-supported-on-touchscreens-in-visual-pla.md +6 -0
  119. package/corpus/templates/plan/shared/plan-content.ts +17 -2
  120. package/dist/cli/skills-content/canvas.d.ts +3 -3
  121. package/dist/cli/skills-content/canvas.js +16 -5
  122. package/dist/cli/skills-content/visual-plan-skill.d.ts +1 -1
  123. package/dist/cli/skills-content/visual-plan-skill.js +27 -0
  124. package/dist/cli/skills-content/wireframe.d.ts +2 -2
  125. package/dist/cli/skills-content/wireframe.js +7 -0
  126. package/dist/client/AgentPanel.js +23 -8
  127. package/dist/client/api-path.d.ts +6 -0
  128. package/dist/client/api-path.js +55 -0
  129. package/dist/client/onboarding/FirstRunOnboarding.d.ts +3 -0
  130. package/dist/client/onboarding/FirstRunOnboarding.js +93 -0
  131. package/dist/client/onboarding/first-run-enabled.d.ts +2 -0
  132. package/dist/client/onboarding/first-run-enabled.js +9 -0
  133. package/dist/client/onboarding/index.d.ts +2 -1
  134. package/dist/client/onboarding/index.js +1 -0
  135. package/dist/client/onboarding/use-onboarding.d.ts +6 -1
  136. package/dist/client/onboarding/use-onboarding.js +32 -1
  137. package/dist/client/route-state.js +10 -0
  138. package/dist/client/settings/SecretsSection.js +32 -8
  139. package/dist/collab/struct-routes.d.ts +1 -1
  140. package/dist/connections/catalog.d.ts +4 -4
  141. package/dist/connections/catalog.js +3 -3
  142. package/dist/db/index.d.ts +1 -1
  143. package/dist/db/index.js +1 -1
  144. package/dist/localization/default-messages.d.ts +5 -0
  145. package/dist/localization/default-messages.js +5 -0
  146. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  147. package/dist/observability/routes.d.ts +3 -3
  148. package/dist/onboarding/app-profile.d.ts +4 -0
  149. package/dist/onboarding/app-profile.js +378 -0
  150. package/dist/onboarding/index.d.ts +2 -1
  151. package/dist/onboarding/index.js +1 -0
  152. package/dist/onboarding/plugin.d.ts +2 -0
  153. package/dist/onboarding/plugin.js +21 -1
  154. package/dist/onboarding/types.d.ts +19 -0
  155. package/dist/progress/routes.d.ts +1 -1
  156. package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
  157. package/dist/provider-api/actions/provider-api.d.ts +12 -12
  158. package/dist/secrets/routes.d.ts +2 -2
  159. package/dist/secrets/routes.js +41 -17
  160. package/dist/server/auth.js +21 -1
  161. package/dist/server/better-auth-instance.d.ts +3 -2
  162. package/dist/server/better-auth-instance.js +9 -7
  163. package/dist/server/email-markdown.js +3 -8
  164. package/dist/server/transcribe-voice.d.ts +1 -1
  165. package/dist/shared/first-run-onboarding.d.ts +3 -0
  166. package/dist/shared/first-run-onboarding.js +3 -0
  167. package/dist/triggers/index.d.ts +3 -0
  168. package/dist/triggers/index.js +5 -0
  169. package/docs/content/cross-app-sso.mdx +26 -0
  170. package/package.json +1 -1
@@ -78,6 +78,12 @@ export async function dispatchPostFinalizeJob(args: {
78
78
  }
79
79
  : {}),
80
80
  });
81
+ console.log("[post-finalize] dispatching", {
82
+ recordingId: args.recordingId,
83
+ kind: args.kind,
84
+ workerUrl,
85
+ usesDurableBackground,
86
+ });
81
87
  const post = (url: string) =>
82
88
  fetch(url, {
83
89
  method: "POST",
@@ -89,7 +95,14 @@ export async function dispatchPostFinalizeJob(args: {
89
95
  usesDurableBackground && !initialResponse.ok
90
96
  ? await post(resolveWorkerUrl(processorRoute))
91
97
  : initialResponse;
92
- if (response.ok) return;
98
+ if (response.ok) {
99
+ console.log("[post-finalize] dispatch accepted", {
100
+ recordingId: args.recordingId,
101
+ kind: args.kind,
102
+ status: response.status,
103
+ });
104
+ return;
105
+ }
93
106
  const detail = (await response.text().catch(() => "")).trim().slice(0, 300);
94
107
  throw new Error(
95
108
  `Post-finalize ${args.kind} worker returned HTTP ${response.status}${
@@ -45,6 +45,15 @@ export default createAuthPlugin({
45
45
  "/api/video",
46
46
  "/api/thumbnail",
47
47
  "/api/auth/google-calendar",
48
+ // Internal post-finalize worker (media verification, seekable remux,
49
+ // transcript, brain-export, loom-import retries). It's a server-to-server
50
+ // self-dispatch with no session cookie — its own scoped, short-lived
51
+ // signed token (verifyScopedAgentAccessToken) is the real auth check, so
52
+ // it must bypass the session gate to ever reach that check. Exact path
53
+ // only, not the whole `_agent-native-background` namespace — a future
54
+ // route added under that prefix without its own auth check must not
55
+ // become silently public by inheriting this bypass.
56
+ "/api/_agent-native-background/post-finalize-worker",
48
57
  "/_agent-native/google/auth-url",
49
58
  "/_agent-native/google/callback",
50
59
  ],
@@ -51,11 +51,17 @@ export default defineEventHandler(async (event: H3Event) => {
51
51
 
52
52
  const { recordingId, kind, token, delayMs, retryAttempt, regenerate } =
53
53
  parsed.data;
54
+ console.log("[post-finalize-worker] received job", { recordingId, kind });
54
55
  const verified = verifyScopedAgentAccessToken(token, {
55
56
  resourceKind: POST_FINALIZE_JOB_TOKEN_KIND,
56
57
  resourceId: postFinalizeJobResourceId(recordingId, kind),
57
58
  });
58
59
  if (!verified.ok) {
60
+ console.warn("[post-finalize-worker] token verification failed", {
61
+ recordingId,
62
+ kind,
63
+ reason: verified.reason,
64
+ });
59
65
  setResponseStatus(event, 401);
60
66
  return { ok: false, error: "Invalid or expired post-finalize job token" };
61
67
  }
@@ -153,6 +159,9 @@ export default defineEventHandler(async (event: H3Event) => {
153
159
  )
154
160
  .returning({ id: schema.recordings.id });
155
161
  if (!claimed) {
162
+ console.log("[post-finalize-worker] loom-import already running", {
163
+ recordingId,
164
+ });
156
165
  return {
157
166
  ok: true,
158
167
  recordingId,
@@ -161,6 +170,10 @@ export default defineEventHandler(async (event: H3Event) => {
161
170
  reason: "loom-import-already-running",
162
171
  };
163
172
  }
173
+ console.log("[post-finalize-worker] loom-import claimed", {
174
+ recordingId,
175
+ claimId,
176
+ });
164
177
  const result = await runLoomImportJob({
165
178
  recordingId,
166
179
  ownerEmail: recording.ownerEmail,
@@ -1399,6 +1399,9 @@ export async function deleteDatabaseDataForDocument(
1399
1399
  db,
1400
1400
  );
1401
1401
  if (database) {
1402
+ await db
1403
+ .delete(schema.contentDatabaseItemKeyClaims)
1404
+ .where(eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id));
1402
1405
  const definitions = await db
1403
1406
  .select({ id: schema.documentPropertyDefinitions.id })
1404
1407
  .from(schema.documentPropertyDefinitions)
@@ -1464,6 +1467,9 @@ export async function deleteDatabaseDataForDocument(
1464
1467
  db,
1465
1468
  );
1466
1469
  if (item) {
1470
+ await db
1471
+ .delete(schema.contentDatabaseItemKeyClaims)
1472
+ .where(eq(schema.contentDatabaseItemKeyClaims.documentId, documentId));
1467
1473
  await db
1468
1474
  .delete(schema.contentDatabaseBodyHydrationQueue)
1469
1475
  .where(
@@ -1,6 +1,6 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { assertAccess } from "@agent-native/core/sharing";
3
- import { and, eq, inArray, ne } from "drizzle-orm";
3
+ import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
@@ -8,7 +8,10 @@ import type {
8
8
  BindContentDatabaseSourceFieldRequest,
9
9
  ContentDatabaseResponse,
10
10
  } from "../shared/api.js";
11
- import { serializePropertyValue } from "../shared/properties.js";
11
+ import {
12
+ serializePropertyValue,
13
+ type DocumentPropertyType,
14
+ } from "../shared/properties.js";
12
15
  import { chunks } from "./_batch-utils.js";
13
16
  import { resolveDatabaseForSourceMutation } from "./_database-source-utils.js";
14
17
  import { getContentDatabaseResponse } from "./_database-utils.js";
@@ -71,40 +74,92 @@ export default defineAction({
71
74
 
72
75
  // ── Unbind ────────────────────────────────────────────────────────────
73
76
  if (args.propertyId === null) {
74
- if (field.propertyId) {
75
- const sourceRows = await db
76
- .select({ documentId: schema.contentDatabaseSourceRows.documentId })
77
- .from(schema.contentDatabaseSourceRows)
78
- .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id));
79
- const sourceDocumentIds = sourceRows
80
- .map((row) => row.documentId)
81
- .filter((id): id is string => Boolean(id));
82
- if (sourceDocumentIds.length > 0) {
83
- await db
84
- .delete(schema.documentPropertyValues)
85
- .where(
86
- and(
87
- eq(schema.documentPropertyValues.propertyId, field.propertyId),
88
- inArray(
89
- schema.documentPropertyValues.documentId,
90
- sourceDocumentIds,
77
+ await db.transaction(async (tx) => {
78
+ const [lockedDatabase] = await tx
79
+ .update(schema.contentDatabases)
80
+ .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` })
81
+ .where(
82
+ and(
83
+ eq(schema.contentDatabases.id, database.id),
84
+ eq(schema.contentDatabases.ownerEmail, database.ownerEmail),
85
+ isNull(schema.contentDatabases.deletedAt),
86
+ ),
87
+ )
88
+ .returning({ id: schema.contentDatabases.id });
89
+ if (!lockedDatabase) throw new Error("Database is no longer active.");
90
+
91
+ const [lockedField] = await tx
92
+ .select()
93
+ .from(schema.contentDatabaseSourceFields)
94
+ .where(eq(schema.contentDatabaseSourceFields.id, field.id));
95
+ if (
96
+ !lockedField ||
97
+ lockedField.sourceId !== source.id ||
98
+ lockedField.mappingType === "title" ||
99
+ lockedField.mappingType === "system" ||
100
+ lockedField.writeOwner === "derived"
101
+ ) {
102
+ throw new Error(
103
+ "Source field changed or was deleted before it could be unbound.",
104
+ );
105
+ }
106
+ if (lockedField.propertyId) {
107
+ const sourceRows = await tx
108
+ .select({
109
+ documentId: schema.contentDatabaseSourceRows.documentId,
110
+ })
111
+ .from(schema.contentDatabaseSourceRows)
112
+ .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id));
113
+ const sourceDocumentIds = sourceRows
114
+ .map((row) => row.documentId)
115
+ .filter((id): id is string => Boolean(id));
116
+ if (sourceDocumentIds.length > 0) {
117
+ await tx
118
+ .delete(schema.documentPropertyValues)
119
+ .where(
120
+ and(
121
+ eq(
122
+ schema.documentPropertyValues.propertyId,
123
+ lockedField.propertyId,
124
+ ),
125
+ inArray(
126
+ schema.documentPropertyValues.documentId,
127
+ sourceDocumentIds,
128
+ ),
91
129
  ),
92
- ),
93
- );
130
+ );
131
+ }
94
132
  }
95
- }
96
- await db
97
- .update(schema.contentDatabaseSourceFields)
98
- .set({
99
- propertyId: null,
100
- localFieldKey: field.sourceFieldKey,
101
- updatedAt: now,
102
- })
103
- .where(eq(schema.contentDatabaseSourceFields.id, field.id));
104
- await db
105
- .update(schema.contentDatabaseSources)
106
- .set({ updatedAt: now })
107
- .where(eq(schema.contentDatabaseSources.id, source.id));
133
+ const [updatedField] = await tx
134
+ .update(schema.contentDatabaseSourceFields)
135
+ .set({
136
+ propertyId: null,
137
+ localFieldKey: lockedField.sourceFieldKey,
138
+ updatedAt: now,
139
+ })
140
+ .where(eq(schema.contentDatabaseSourceFields.id, lockedField.id))
141
+ .returning({ id: schema.contentDatabaseSourceFields.id });
142
+ if (!updatedField) {
143
+ throw new Error(
144
+ "Source field was deleted before its unbinding could be saved.",
145
+ );
146
+ }
147
+ const [updatedSource] = await tx
148
+ .update(schema.contentDatabaseSources)
149
+ .set({ updatedAt: now })
150
+ .where(
151
+ and(
152
+ eq(schema.contentDatabaseSources.id, source.id),
153
+ eq(schema.contentDatabaseSources.databaseId, database.id),
154
+ ),
155
+ )
156
+ .returning({ id: schema.contentDatabaseSources.id });
157
+ if (!updatedSource) {
158
+ throw new Error(
159
+ "Source was deleted before its field unbinding could be saved.",
160
+ );
161
+ }
162
+ });
108
163
  return getContentDatabaseResponse(database.id, { limit: 100, offset: 0 });
109
164
  }
110
165
 
@@ -183,23 +238,6 @@ export default defineAction({
183
238
  );
184
239
  }
185
240
 
186
- await db
187
- .update(schema.contentDatabaseSourceFields)
188
- .set({
189
- propertyId: property.id,
190
- localFieldKey: property.id,
191
- mappingType: "property",
192
- updatedAt: now,
193
- })
194
- .where(eq(schema.contentDatabaseSourceFields.id, field.id));
195
- await db
196
- .update(schema.contentDatabaseSources)
197
- .set({ updatedAt: now })
198
- .where(eq(schema.contentDatabaseSources.id, source.id));
199
-
200
- // Backfill the column with this source's per-row values. A federated
201
- // secondary's rows carry no local document (the read path overlays them),
202
- // so only materialize for document-backed sources.
203
241
  let federationRole: string | null = null;
204
242
  try {
205
243
  const parsed = JSON.parse(source.metadataJson ?? "{}") as {
@@ -209,57 +247,195 @@ export default defineAction({
209
247
  } catch {
210
248
  federationRole = null;
211
249
  }
212
- if (federationRole !== "secondary") {
213
- const sourceRows = await db
214
- .select({
215
- databaseItemId: schema.contentDatabaseSourceRows.databaseItemId,
216
- documentId: schema.contentDatabaseSourceRows.documentId,
217
- sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson,
250
+ await db.transaction(async (tx) => {
251
+ // Share the database-row lock used by stable-key upserts. This makes the
252
+ // claim check and source binding one atomic ownership transition: either
253
+ // the property remains caller-managed, or binding fails before backfill.
254
+ const [lockedDatabase] = await tx
255
+ .update(schema.contentDatabases)
256
+ .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` })
257
+ .where(
258
+ and(
259
+ eq(schema.contentDatabases.id, database.id),
260
+ eq(schema.contentDatabases.documentId, database.documentId),
261
+ eq(schema.contentDatabases.ownerEmail, database.ownerEmail),
262
+ isNull(schema.contentDatabases.deletedAt),
263
+ ),
264
+ )
265
+ .returning({ id: schema.contentDatabases.id });
266
+ if (!lockedDatabase) throw new Error("Database is no longer active.");
267
+
268
+ const [lockedProperty] = await tx
269
+ .update(schema.documentPropertyDefinitions)
270
+ .set({
271
+ updatedAt: sql`${schema.documentPropertyDefinitions.updatedAt}`,
218
272
  })
219
- .from(schema.contentDatabaseSourceRows)
220
- .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id));
221
- const itemValues = sourceFieldPropertyValuesFromRows(
222
- sourceRows,
223
- field.sourceFieldKey,
224
- property.type,
225
- );
226
- // Clear this column's values for ALL of this source's rows first — not
227
- // just the rows that now have a value — so a row whose new bound field is
228
- // empty doesn't keep showing a stale/previous value. Then write the
229
- // non-empty ones. (This source owns these documents' values for the row-
230
- // union, so clearing them is safe.)
231
- const sourceDocumentIds = sourceRows
232
- .map((row) => row.documentId)
233
- .filter((id): id is string => Boolean(id));
234
- if (sourceDocumentIds.length > 0) {
235
- await db
236
- .delete(schema.documentPropertyValues)
237
- .where(
238
- and(
239
- eq(schema.documentPropertyValues.propertyId, property.id),
240
- inArray(
241
- schema.documentPropertyValues.documentId,
242
- sourceDocumentIds,
243
- ),
273
+ .where(
274
+ and(
275
+ eq(schema.documentPropertyDefinitions.id, property.id),
276
+ eq(schema.documentPropertyDefinitions.databaseId, database.id),
277
+ eq(
278
+ schema.documentPropertyDefinitions.ownerEmail,
279
+ database.ownerEmail,
244
280
  ),
245
- );
281
+ ),
282
+ )
283
+ .returning();
284
+ if (
285
+ !lockedProperty ||
286
+ lockedProperty.type !== property.type ||
287
+ lockedProperty.systemRole !== property.systemRole ||
288
+ lockedProperty.name !== property.name
289
+ ) {
290
+ throw new Error(
291
+ "Target column changed or was deleted before the source field could be bound.",
292
+ );
246
293
  }
247
- if (itemValues.length > 0) {
248
- for (const chunk of chunks(itemValues, 200)) {
249
- await db.insert(schema.documentPropertyValues).values(
250
- chunk.map((row) => ({
251
- id: nanoid(),
252
- ownerEmail: database.ownerEmail,
253
- documentId: row.documentId,
254
- propertyId: property.id,
255
- valueJson: serializePropertyValue(row.value),
256
- createdAt: now,
257
- updatedAt: now,
258
- })),
259
- );
294
+
295
+ const [lockedField] = await tx
296
+ .select()
297
+ .from(schema.contentDatabaseSourceFields)
298
+ .where(eq(schema.contentDatabaseSourceFields.id, field.id));
299
+ if (
300
+ !lockedField ||
301
+ lockedField.sourceId !== source.id ||
302
+ lockedField.mappingType === "title" ||
303
+ lockedField.mappingType === "system" ||
304
+ lockedField.writeOwner === "derived"
305
+ ) {
306
+ throw new Error(
307
+ "Source field changed or was deleted before it could be bound.",
308
+ );
309
+ }
310
+ if (
311
+ lockedField.propertyId &&
312
+ lockedField.propertyId !== lockedProperty.id
313
+ ) {
314
+ throw new Error(
315
+ "This source field is already bound to another column. Unbind it first.",
316
+ );
317
+ }
318
+ const [lockedConflictingField] = await tx
319
+ .select({ id: schema.contentDatabaseSourceFields.id })
320
+ .from(schema.contentDatabaseSourceFields)
321
+ .where(
322
+ and(
323
+ eq(schema.contentDatabaseSourceFields.sourceId, source.id),
324
+ eq(
325
+ schema.contentDatabaseSourceFields.propertyId,
326
+ lockedProperty.id,
327
+ ),
328
+ ne(schema.contentDatabaseSourceFields.id, lockedField.id),
329
+ ),
330
+ );
331
+ if (lockedConflictingField) {
332
+ throw new Error(
333
+ "This source already feeds this column from another field. Unbind it first.",
334
+ );
335
+ }
336
+
337
+ const [activeClaim] = await tx
338
+ .select({ id: schema.contentDatabaseItemKeyClaims.id })
339
+ .from(schema.contentDatabaseItemKeyClaims)
340
+ .where(
341
+ and(
342
+ eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id),
343
+ eq(schema.contentDatabaseItemKeyClaims.propertyId, property.id),
344
+ ),
345
+ )
346
+ .limit(1);
347
+ if (activeClaim) {
348
+ throw new Error(
349
+ "A property with active stable-key claims cannot be bound to a source field.",
350
+ );
351
+ }
352
+
353
+ const [updatedField] = await tx
354
+ .update(schema.contentDatabaseSourceFields)
355
+ .set({
356
+ propertyId: property.id,
357
+ localFieldKey: property.id,
358
+ mappingType: "property",
359
+ updatedAt: now,
360
+ })
361
+ .where(eq(schema.contentDatabaseSourceFields.id, lockedField.id))
362
+ .returning({ id: schema.contentDatabaseSourceFields.id });
363
+ if (!updatedField) {
364
+ throw new Error(
365
+ "Source field was deleted before its binding could be saved.",
366
+ );
367
+ }
368
+ const [updatedSource] = await tx
369
+ .update(schema.contentDatabaseSources)
370
+ .set({ updatedAt: now })
371
+ .where(
372
+ and(
373
+ eq(schema.contentDatabaseSources.id, source.id),
374
+ eq(schema.contentDatabaseSources.databaseId, database.id),
375
+ ),
376
+ )
377
+ .returning({ id: schema.contentDatabaseSources.id });
378
+ if (!updatedSource) {
379
+ throw new Error(
380
+ "Source was deleted before its field binding could be saved.",
381
+ );
382
+ }
383
+
384
+ // Backfill the column with this source's per-row values. A federated
385
+ // secondary's rows carry no local document (the read path overlays them),
386
+ // so only materialize for document-backed sources.
387
+ if (federationRole !== "secondary") {
388
+ const sourceRows = await tx
389
+ .select({
390
+ databaseItemId: schema.contentDatabaseSourceRows.databaseItemId,
391
+ documentId: schema.contentDatabaseSourceRows.documentId,
392
+ sourceValuesJson: schema.contentDatabaseSourceRows.sourceValuesJson,
393
+ })
394
+ .from(schema.contentDatabaseSourceRows)
395
+ .where(eq(schema.contentDatabaseSourceRows.sourceId, source.id));
396
+ const itemValues = sourceFieldPropertyValuesFromRows(
397
+ sourceRows,
398
+ lockedField.sourceFieldKey,
399
+ lockedProperty.type as DocumentPropertyType,
400
+ );
401
+ // Clear this column's values for ALL of this source's rows first — not
402
+ // just the rows that now have a value — so a row whose new bound field is
403
+ // empty doesn't keep showing a stale/previous value. Then write the
404
+ // non-empty ones. (This source owns these documents' values for the row-
405
+ // union, so clearing them is safe.)
406
+ const sourceDocumentIds = sourceRows
407
+ .map((row) => row.documentId)
408
+ .filter((id): id is string => Boolean(id));
409
+ if (sourceDocumentIds.length > 0) {
410
+ await tx
411
+ .delete(schema.documentPropertyValues)
412
+ .where(
413
+ and(
414
+ eq(schema.documentPropertyValues.propertyId, property.id),
415
+ inArray(
416
+ schema.documentPropertyValues.documentId,
417
+ sourceDocumentIds,
418
+ ),
419
+ ),
420
+ );
421
+ }
422
+ if (itemValues.length > 0) {
423
+ for (const chunk of chunks(itemValues, 200)) {
424
+ await tx.insert(schema.documentPropertyValues).values(
425
+ chunk.map((row) => ({
426
+ id: nanoid(),
427
+ ownerEmail: database.ownerEmail,
428
+ documentId: row.documentId,
429
+ propertyId: property.id,
430
+ valueJson: serializePropertyValue(row.value),
431
+ createdAt: now,
432
+ updatedAt: now,
433
+ })),
434
+ );
435
+ }
260
436
  }
261
437
  }
262
- }
438
+ });
263
439
 
264
440
  return getContentDatabaseResponse(database.id, { limit: 100, offset: 0 });
265
441
  },
@@ -1,6 +1,6 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { assertAccess } from "@agent-native/core/sharing";
3
- import { and, asc, eq, inArray, ne } from "drizzle-orm";
3
+ import { and, asc, eq, inArray, isNull, ne, sql } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
@@ -157,41 +157,77 @@ async function clearSourceFederation(sourceId: string, now: string) {
157
157
  });
158
158
  }
159
159
 
160
- async function removeRowsOwnedOnlyBySource(args: {
160
+ export async function removeRowsOwnedOnlyBySource(args: {
161
161
  databaseId: string;
162
162
  sourceId: string;
163
163
  }) {
164
164
  const db = getDb();
165
- const [targetRows, otherRows] = await Promise.all([
166
- db
167
- .select()
168
- .from(schema.contentDatabaseSourceRows)
169
- .where(eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId)),
170
- db
165
+ await db.transaction(async (tx) => {
166
+ const [lockedDatabase] = await tx
167
+ .update(schema.contentDatabases)
168
+ .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` })
169
+ .where(
170
+ and(
171
+ eq(schema.contentDatabases.id, args.databaseId),
172
+ isNull(schema.contentDatabases.deletedAt),
173
+ ),
174
+ )
175
+ .returning({ id: schema.contentDatabases.id });
176
+ if (!lockedDatabase) throw new Error("Database is no longer active.");
177
+
178
+ const targetRows = await tx
171
179
  .select()
172
180
  .from(schema.contentDatabaseSourceRows)
173
- .where(ne(schema.contentDatabaseSourceRows.sourceId, args.sourceId)),
174
- ]);
175
- const otherDocumentIds = new Set(
176
- otherRows.map((row) => row.documentId).filter(Boolean),
177
- );
178
- const itemIds = targetRows
179
- .filter(
180
- (row) =>
181
- row.databaseItemId &&
182
- row.documentId &&
183
- !otherDocumentIds.has(row.documentId),
184
- )
185
- .map((row) => row.databaseItemId);
186
- if (itemIds.length === 0) return;
187
- await db
188
- .delete(schema.contentDatabaseItems)
189
- .where(
190
- and(
191
- eq(schema.contentDatabaseItems.databaseId, args.databaseId),
192
- inArray(schema.contentDatabaseItems.id, itemIds),
193
- ),
181
+ .where(eq(schema.contentDatabaseSourceRows.sourceId, args.sourceId));
182
+ const siblingSources = await tx
183
+ .select({ id: schema.contentDatabaseSources.id })
184
+ .from(schema.contentDatabaseSources)
185
+ .where(
186
+ and(
187
+ eq(schema.contentDatabaseSources.databaseId, args.databaseId),
188
+ ne(schema.contentDatabaseSources.id, args.sourceId),
189
+ ),
190
+ );
191
+ const otherRows = siblingSources.length
192
+ ? await tx
193
+ .select()
194
+ .from(schema.contentDatabaseSourceRows)
195
+ .where(
196
+ inArray(
197
+ schema.contentDatabaseSourceRows.sourceId,
198
+ siblingSources.map((source) => source.id),
199
+ ),
200
+ )
201
+ : [];
202
+ const otherDocumentIds = new Set(
203
+ otherRows.map((row) => row.documentId).filter(Boolean),
194
204
  );
205
+ const itemIds = targetRows
206
+ .filter(
207
+ (row) =>
208
+ row.databaseItemId &&
209
+ row.documentId &&
210
+ !otherDocumentIds.has(row.documentId),
211
+ )
212
+ .map((row) => row.databaseItemId as string);
213
+ if (itemIds.length === 0) return;
214
+ await tx
215
+ .delete(schema.contentDatabaseItemKeyClaims)
216
+ .where(
217
+ and(
218
+ eq(schema.contentDatabaseItemKeyClaims.databaseId, args.databaseId),
219
+ inArray(schema.contentDatabaseItemKeyClaims.itemId, itemIds),
220
+ ),
221
+ );
222
+ await tx
223
+ .delete(schema.contentDatabaseItems)
224
+ .where(
225
+ and(
226
+ eq(schema.contentDatabaseItems.databaseId, args.databaseId),
227
+ inArray(schema.contentDatabaseItems.id, itemIds),
228
+ ),
229
+ );
230
+ });
195
231
  }
196
232
 
197
233
  export async function readBuilderCmsEntriesForRoleChange(