@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
@@ -1,7 +1,7 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { writeAppState } from "@agent-native/core/application-state";
3
3
  import { assertAccess } from "@agent-native/core/sharing";
4
- import { and, eq, sql } from "drizzle-orm";
4
+ import { and, eq, isNull, sql } from "drizzle-orm";
5
5
  import { z } from "zod";
6
6
 
7
7
  import { getDb, schema } from "../server/db/index.js";
@@ -114,12 +114,13 @@ export default defineAction({
114
114
  }
115
115
 
116
116
  if (args.id) {
117
+ const propertyId = args.id;
117
118
  const [existing] = await db
118
119
  .select()
119
120
  .from(schema.documentPropertyDefinitions)
120
121
  .where(
121
122
  and(
122
- eq(schema.documentPropertyDefinitions.id, args.id),
123
+ eq(schema.documentPropertyDefinitions.id, propertyId),
123
124
  eq(
124
125
  schema.documentPropertyDefinitions.ownerEmail,
125
126
  document.ownerEmail,
@@ -127,7 +128,7 @@ export default defineAction({
127
128
  eq(schema.documentPropertyDefinitions.databaseId, database.id),
128
129
  ),
129
130
  );
130
- if (!existing) throw new Error(`Property "${args.id}" not found`);
131
+ if (!existing) throw new Error(`Property "${propertyId}" not found`);
131
132
  if (existing.systemRole) {
132
133
  throw new Error("System properties cannot be changed.");
133
134
  }
@@ -158,42 +159,122 @@ export default defineAction({
158
159
  optionsJson = serializePropertyOptions({ blocks: { primary: true } });
159
160
  }
160
161
 
161
- if (existing.type !== type) {
162
- await db
163
- .delete(schema.documentPropertyValues)
162
+ await db.transaction(async (tx) => {
163
+ const [lockedDatabase] = await tx
164
+ .update(schema.contentDatabases)
165
+ .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` })
164
166
  .where(
165
167
  and(
166
- eq(schema.documentPropertyValues.propertyId, args.id),
167
- eq(schema.documentPropertyValues.ownerEmail, document.ownerEmail),
168
+ eq(schema.contentDatabases.id, database.id),
169
+ eq(schema.contentDatabases.documentId, database.documentId),
170
+ eq(schema.contentDatabases.ownerEmail, document.ownerEmail),
171
+ isNull(schema.contentDatabases.deletedAt),
168
172
  ),
169
- );
170
- // Switching a Blocks field to another type drops its independent content.
173
+ )
174
+ .returning({ id: schema.contentDatabases.id });
175
+ if (!lockedDatabase) throw new Error("Database is no longer active.");
176
+ const [lockedExisting] = await tx
177
+ .update(schema.documentPropertyDefinitions)
178
+ .set({
179
+ updatedAt: sql`${schema.documentPropertyDefinitions.updatedAt}`,
180
+ })
181
+ .where(
182
+ and(
183
+ eq(schema.documentPropertyDefinitions.id, propertyId),
184
+ eq(
185
+ schema.documentPropertyDefinitions.ownerEmail,
186
+ document.ownerEmail,
187
+ ),
188
+ eq(schema.documentPropertyDefinitions.databaseId, database.id),
189
+ ),
190
+ )
191
+ .returning();
192
+ if (!lockedExisting)
193
+ throw new Error(`Property "${propertyId}" not found`);
194
+ if (lockedExisting.systemRole)
195
+ throw new Error("System properties cannot be changed.");
171
196
  if (
172
- isBlocksPropertyType(existing.type as DocumentPropertyType) &&
173
- !isBlocksPropertyType(type)
197
+ isComputedPropertyType(lockedExisting.type as DocumentPropertyType) &&
198
+ lockedExisting.type !== type
174
199
  ) {
175
- await db
176
- .delete(schema.documentBlockFieldContents)
177
- .where(eq(schema.documentBlockFieldContents.propertyId, args.id));
200
+ throw new Error("Computed property types cannot be changed.");
201
+ }
202
+ const lockedIsPrimaryBlocks =
203
+ isBlocksPropertyType(lockedExisting.type as DocumentPropertyType) &&
204
+ isPrimaryBlocksField(
205
+ parsePropertyOptions(lockedExisting.optionsJson),
206
+ );
207
+ if (lockedIsPrimaryBlocks && lockedExisting.type !== type) {
208
+ throw new Error(
209
+ "The primary Content (Blocks) field cannot change type. Delete it from the database view to remove the body.",
210
+ );
178
211
  }
179
- }
180
212
 
181
- await db
182
- .update(schema.documentPropertyDefinitions)
183
- .set({
184
- name,
185
- ...(args.description === undefined
186
- ? {}
187
- : { description: args.description.trim() }),
188
- type,
189
- visibility:
190
- args.visibility === undefined
191
- ? normalizePropertyVisibility(existing.visibility)
192
- : normalizePropertyVisibility(args.visibility),
193
- optionsJson,
194
- updatedAt: now,
195
- })
196
- .where(eq(schema.documentPropertyDefinitions.id, args.id));
213
+ if (lockedExisting.type !== type) {
214
+ const [mappedSourceField] = await tx
215
+ .select({ id: schema.contentDatabaseSourceFields.id })
216
+ .from(schema.contentDatabaseSourceFields)
217
+ .where(
218
+ eq(schema.contentDatabaseSourceFields.propertyId, propertyId),
219
+ )
220
+ .limit(1);
221
+ if (mappedSourceField) {
222
+ throw new Error(
223
+ "A property bound to a source field must be unbound before changing its type.",
224
+ );
225
+ }
226
+ await tx
227
+ .delete(schema.documentPropertyValues)
228
+ .where(
229
+ and(
230
+ eq(schema.documentPropertyValues.propertyId, propertyId),
231
+ eq(
232
+ schema.documentPropertyValues.ownerEmail,
233
+ document.ownerEmail,
234
+ ),
235
+ ),
236
+ );
237
+ await tx
238
+ .delete(schema.contentDatabaseItemKeyClaims)
239
+ .where(
240
+ and(
241
+ eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id),
242
+ eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId),
243
+ ),
244
+ );
245
+ // Switching a Blocks field to another type drops its independent content.
246
+ if (
247
+ isBlocksPropertyType(lockedExisting.type as DocumentPropertyType) &&
248
+ !isBlocksPropertyType(type)
249
+ ) {
250
+ await tx
251
+ .delete(schema.documentBlockFieldContents)
252
+ .where(
253
+ eq(schema.documentBlockFieldContents.propertyId, propertyId),
254
+ );
255
+ }
256
+ }
257
+
258
+ await tx
259
+ .update(schema.documentPropertyDefinitions)
260
+ .set({
261
+ name,
262
+ ...(args.description === undefined
263
+ ? {}
264
+ : { description: args.description.trim() }),
265
+ type,
266
+ visibility:
267
+ args.visibility === undefined
268
+ ? normalizePropertyVisibility(lockedExisting.visibility)
269
+ : normalizePropertyVisibility(args.visibility),
270
+ optionsJson:
271
+ lockedIsPrimaryBlocks && isBlocksPropertyType(type)
272
+ ? serializePropertyOptions({ blocks: { primary: true } })
273
+ : optionsJson,
274
+ updatedAt: now,
275
+ })
276
+ .where(eq(schema.documentPropertyDefinitions.id, propertyId));
277
+ });
197
278
  } else {
198
279
  await withPositionLock(
199
280
  propertyDefinitionsPositionScope(database.id),
@@ -21,13 +21,11 @@ export default defineAction({
21
21
  await assertAccess("document", database.documentId, "admin");
22
22
  const db = getDb();
23
23
  const deletedAt = database.deletedAt ?? new Date().toISOString();
24
- await db.transaction((tx) =>
25
- trashDocumentSubtree(
26
- tx as unknown as ReturnType<typeof getDb>,
27
- database.documentId,
28
- database.ownerEmail,
29
- deletedAt,
30
- ),
24
+ await trashDocumentSubtree(
25
+ db,
26
+ database.documentId,
27
+ database.ownerEmail,
28
+ deletedAt,
31
29
  );
32
30
 
33
31
  await writeAppState("refresh-signal", { ts: Date.now() });
@@ -1,7 +1,7 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { writeAppState } from "@agent-native/core/application-state";
3
3
  import { assertAccess } from "@agent-native/core/sharing";
4
- import { and, eq, inArray } from "drizzle-orm";
4
+ import { and, eq, inArray, isNull, sql } from "drizzle-orm";
5
5
  import { z } from "zod";
6
6
 
7
7
  import { getDb, schema } from "../server/db/index.js";
@@ -65,74 +65,121 @@ export default defineAction({
65
65
  isBlocks &&
66
66
  isPrimaryBlocksField(parsePropertyOptions(definition.optionsJson));
67
67
 
68
- await db
69
- .delete(schema.documentPropertyValues)
70
- .where(eq(schema.documentPropertyValues.propertyId, propertyId));
71
- await db
72
- .delete(schema.documentPropertyDefinitions)
73
- .where(eq(schema.documentPropertyDefinitions.id, propertyId));
68
+ await db.transaction(async (tx) => {
69
+ const [lockedDatabase] = await tx
70
+ .update(schema.contentDatabases)
71
+ .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` })
72
+ .where(
73
+ and(
74
+ eq(schema.contentDatabases.id, database.id),
75
+ eq(schema.contentDatabases.documentId, database.documentId),
76
+ eq(schema.contentDatabases.ownerEmail, document.ownerEmail),
77
+ isNull(schema.contentDatabases.deletedAt),
78
+ ),
79
+ )
80
+ .returning({ id: schema.contentDatabases.id });
81
+ if (!lockedDatabase) throw new Error("Database is no longer active.");
82
+ const [lockedDefinition] = await tx
83
+ .update(schema.documentPropertyDefinitions)
84
+ .set({
85
+ updatedAt: sql`${schema.documentPropertyDefinitions.updatedAt}`,
86
+ })
87
+ .where(
88
+ and(
89
+ eq(schema.documentPropertyDefinitions.id, propertyId),
90
+ eq(
91
+ schema.documentPropertyDefinitions.ownerEmail,
92
+ document.ownerEmail,
93
+ ),
94
+ eq(schema.documentPropertyDefinitions.databaseId, database.id),
95
+ ),
96
+ )
97
+ .returning({
98
+ id: schema.documentPropertyDefinitions.id,
99
+ systemRole: schema.documentPropertyDefinitions.systemRole,
100
+ });
101
+ if (!lockedDefinition)
102
+ throw new Error(`Property "${propertyId}" not found`);
103
+ if (lockedDefinition.systemRole)
104
+ throw new Error("System properties cannot be deleted.");
105
+
106
+ await tx
107
+ .delete(schema.documentPropertyValues)
108
+ .where(eq(schema.documentPropertyValues.propertyId, propertyId));
109
+ await tx
110
+ .delete(schema.contentDatabaseItemKeyClaims)
111
+ .where(
112
+ and(
113
+ eq(schema.contentDatabaseItemKeyClaims.databaseId, database.id),
114
+ eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId),
115
+ ),
116
+ );
117
+ await tx
118
+ .delete(schema.documentPropertyDefinitions)
119
+ .where(eq(schema.documentPropertyDefinitions.id, propertyId));
74
120
 
75
- if (isBlocks) {
76
- // Drop the independent content for this Blocks field across every row.
77
- await db
78
- .delete(schema.documentBlockFieldContents)
79
- .where(eq(schema.documentBlockFieldContents.propertyId, propertyId));
121
+ if (isBlocks) {
122
+ // Drop the independent content for this Blocks field across every row.
123
+ await tx
124
+ .delete(schema.documentBlockFieldContents)
125
+ .where(eq(schema.documentBlockFieldContents.propertyId, propertyId));
80
126
 
81
- // Deleting the primary "Content" field removes the body (documents.content)
82
- // for every object of this type, per the delete warning shown in the UI.
83
- if (isPrimaryBlocks) {
84
- // Record that the primary was intentionally removed: clear the single
85
- // source of truth but LEAVE blocks_seeded = 1, so neither the read path
86
- // nor the startup repair ever recreates it. Deleting the only Blocks
87
- // field is an allowed product action that leaves the row metadata-only
88
- // with ZERO Blocks fields.
89
- await db
90
- .update(schema.contentDatabases)
91
- .set({
92
- primaryBlocksPropertyId: null,
93
- updatedAt: new Date().toISOString(),
94
- })
95
- .where(eq(schema.contentDatabases.id, database.id));
127
+ // Deleting the primary "Content" field removes the body (documents.content)
128
+ // for every object of this type, per the delete warning shown in the UI.
129
+ if (isPrimaryBlocks) {
130
+ // Record that the primary was intentionally removed: clear the single
131
+ // source of truth but LEAVE blocks_seeded = 1, so neither the read path
132
+ // nor the startup repair ever recreates it. Deleting the only Blocks
133
+ // field is an allowed product action that leaves the row metadata-only
134
+ // with ZERO Blocks fields.
135
+ await tx
136
+ .update(schema.contentDatabases)
137
+ .set({
138
+ primaryBlocksPropertyId: null,
139
+ updatedAt: new Date().toISOString(),
140
+ })
141
+ .where(eq(schema.contentDatabases.id, database.id));
96
142
 
97
- const items = await db
98
- .select({ documentId: schema.contentDatabaseItems.documentId })
99
- .from(schema.contentDatabaseItems)
100
- .where(eq(schema.contentDatabaseItems.databaseId, database.id));
101
- const documentIds = items.map((item) => item.documentId);
102
- if (documentIds.length > 0) {
103
- const now = new Date().toISOString();
104
- await db
105
- .update(schema.documents)
106
- .set({ content: "", updatedAt: now })
107
- .where(inArray(schema.documents.id, documentIds));
143
+ const items = await tx
144
+ .select({ documentId: schema.contentDatabaseItems.documentId })
145
+ .from(schema.contentDatabaseItems)
146
+ .where(eq(schema.contentDatabaseItems.databaseId, database.id));
147
+ const documentIds = items.map((item) => item.documentId);
148
+ if (documentIds.length > 0) {
149
+ const now = new Date().toISOString();
150
+ await tx
151
+ .update(schema.documents)
152
+ .set({ content: "", updatedAt: now })
153
+ .where(inArray(schema.documents.id, documentIds));
154
+ }
108
155
  }
109
156
  }
110
- }
111
157
 
112
- // Free any source field that was mapped to this property so it returns to
113
- // the "From source" picker immediately, instead of staying orphaned until
114
- // the next source refresh reconciles it.
115
- const mappedFields = await db
116
- .select({
117
- id: schema.contentDatabaseSourceFields.id,
118
- sourceFieldKey: schema.contentDatabaseSourceFields.sourceFieldKey,
119
- })
120
- .from(schema.contentDatabaseSourceFields)
121
- .where(eq(schema.contentDatabaseSourceFields.propertyId, propertyId));
122
- if (mappedFields.length > 0) {
123
- const now = new Date().toISOString();
124
- for (const mapped of mappedFields) {
125
- await db
126
- .update(schema.contentDatabaseSourceFields)
127
- .set({
128
- propertyId: null,
129
- localFieldKey: mapped.sourceFieldKey,
130
- mappingType: "property",
131
- updatedAt: now,
132
- })
133
- .where(eq(schema.contentDatabaseSourceFields.id, mapped.id));
158
+ // Free any source field that was mapped to this property so it returns to
159
+ // the "From source" picker immediately, instead of staying orphaned until
160
+ // the next source refresh reconciles it.
161
+ const mappedFields = await tx
162
+ .select({
163
+ id: schema.contentDatabaseSourceFields.id,
164
+ sourceFieldKey: schema.contentDatabaseSourceFields.sourceFieldKey,
165
+ })
166
+ .from(schema.contentDatabaseSourceFields)
167
+ .where(eq(schema.contentDatabaseSourceFields.propertyId, propertyId));
168
+ if (mappedFields.length > 0) {
169
+ const now = new Date().toISOString();
170
+ for (const mapped of mappedFields) {
171
+ await tx
172
+ .update(schema.contentDatabaseSourceFields)
173
+ .set({
174
+ propertyId: null,
175
+ localFieldKey: mapped.sourceFieldKey,
176
+ mappingType: "property",
177
+ updatedAt: now,
178
+ })
179
+ .where(eq(schema.contentDatabaseSourceFields.id, mapped.id));
180
+ }
134
181
  }
135
- }
182
+ });
136
183
 
137
184
  await writeAppState("refresh-signal", { ts: Date.now() });
138
185
 
@@ -1,7 +1,7 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { writeAppState } from "@agent-native/core/application-state";
3
3
  import { assertAccess } from "@agent-native/core/sharing";
4
- import { and, eq, inArray, isNotNull, isNull, ne, or } from "drizzle-orm";
4
+ import { and, eq, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm";
5
5
  import { z } from "zod";
6
6
 
7
7
  import { getDb, schema } from "../server/db/index.js";
@@ -97,6 +97,28 @@ async function selectDatabaseItemDocuments(
97
97
  return ownedRows.map((row) => ({ documentId: row.id }));
98
98
  }
99
99
 
100
+ async function selectMembershipDatabaseIds(
101
+ db: ReturnType<typeof getDb>,
102
+ documentIds: string[],
103
+ ownerEmail: string,
104
+ ) {
105
+ const rows: Array<{ databaseId: string }> = [];
106
+ for (const batch of chunks(documentIds, DELETE_BATCH_SIZE)) {
107
+ rows.push(
108
+ ...(await db
109
+ .select({ databaseId: schema.contentDatabaseItems.databaseId })
110
+ .from(schema.contentDatabaseItems)
111
+ .where(
112
+ and(
113
+ inArray(schema.contentDatabaseItems.documentId, batch),
114
+ eq(schema.contentDatabaseItems.ownerEmail, ownerEmail),
115
+ ),
116
+ )),
117
+ );
118
+ }
119
+ return [...new Set(rows.map((row) => row.databaseId))];
120
+ }
121
+
100
122
  async function collectDocumentSubtreeForDelete(
101
123
  db: ReturnType<typeof getDb>,
102
124
  rootId: string,
@@ -150,22 +172,83 @@ async function collectDocumentSubtreeForDelete(
150
172
  frontier = [...next];
151
173
  }
152
174
 
175
+ const collectedDocumentIds = [...documentIds];
176
+ const collectedOwnedDatabaseIds = [...ownedDatabaseIds];
153
177
  return {
154
- documentIds: [...documentIds],
155
- ownedDatabaseIds: [...ownedDatabaseIds],
178
+ documentIds: collectedDocumentIds,
179
+ ownedDatabaseIds: collectedOwnedDatabaseIds,
180
+ lockDatabaseIds: [
181
+ ...new Set([
182
+ ...collectedOwnedDatabaseIds,
183
+ ...(await selectMembershipDatabaseIds(
184
+ db,
185
+ collectedDocumentIds,
186
+ ownerEmail,
187
+ )),
188
+ ]),
189
+ ],
156
190
  };
157
191
  }
158
192
 
193
+ async function lockDatabasesAndRecollect<
194
+ T extends { lockDatabaseIds: string[] },
195
+ >(
196
+ db: ReturnType<typeof getDb>,
197
+ ownerEmail: string,
198
+ collect: () => Promise<T>,
199
+ ): Promise<T> {
200
+ const lockedDatabaseIds = new Set<string>();
201
+ let collected = await collect();
202
+ while (true) {
203
+ const unlockedDatabaseIds = collected.lockDatabaseIds.filter(
204
+ (databaseId) => !lockedDatabaseIds.has(databaseId),
205
+ );
206
+ if (unlockedDatabaseIds.length === 0) return collected;
207
+ for (const batch of chunks(unlockedDatabaseIds, DELETE_BATCH_SIZE)) {
208
+ await db
209
+ .update(schema.contentDatabases)
210
+ .set({ updatedAt: sql`${schema.contentDatabases.updatedAt}` })
211
+ .where(
212
+ and(
213
+ inArray(schema.contentDatabases.id, batch),
214
+ eq(schema.contentDatabases.ownerEmail, ownerEmail),
215
+ ),
216
+ );
217
+ for (const databaseId of batch) lockedDatabaseIds.add(databaseId);
218
+ }
219
+ collected = await collect();
220
+ }
221
+ }
222
+
159
223
  export async function trashDocumentSubtree(
160
224
  db: ReturnType<typeof getDb>,
161
225
  id: string,
162
226
  ownerEmail: string,
163
227
  trashedAt = new Date().toISOString(),
164
228
  ): Promise<string[]> {
165
- const { documentIds } = await collectDocumentSubtreeForDelete(
166
- db,
167
- id,
168
- ownerEmail,
229
+ return db.transaction((tx) =>
230
+ trashDocumentSubtreeInTransaction(
231
+ tx as unknown as ReturnType<typeof getDb>,
232
+ id,
233
+ ownerEmail,
234
+ trashedAt,
235
+ ),
236
+ );
237
+ }
238
+
239
+ async function trashDocumentSubtreeInTransaction(
240
+ db: ReturnType<typeof getDb>,
241
+ id: string,
242
+ ownerEmail: string,
243
+ trashedAt: string,
244
+ ): Promise<string[]> {
245
+ // Stable-key upserts lock their canonical database row before creating or
246
+ // updating children. Acquire the same locks, then collect again: an upsert
247
+ // that won the lock before this transaction may have added a child after the
248
+ // first traversal, while one that arrives later must wait until the database
249
+ // has been marked deleted and will fail closed.
250
+ const { documentIds } = await lockDatabasesAndRecollect(db, ownerEmail, () =>
251
+ collectDocumentSubtreeForDelete(db, id, ownerEmail),
169
252
  );
170
253
  await assertNotWorkspaceCatalogDocuments(db, documentIds, "deleted");
171
254
 
@@ -316,14 +399,20 @@ export async function deleteDocumentRecursive(
316
399
  id: string,
317
400
  ownerEmail: string,
318
401
  ): Promise<string[]> {
319
- const { documentIds, ownedDatabaseIds } =
320
- await collectDocumentSubtreeForDelete(db, id, ownerEmail);
321
- return deleteCollectedDocuments(
322
- db,
323
- documentIds,
324
- ownedDatabaseIds,
325
- ownerEmail,
326
- );
402
+ return db.transaction(async (tx) => {
403
+ const scopedDb = tx as unknown as ReturnType<typeof getDb>;
404
+ const { documentIds, ownedDatabaseIds } = await lockDatabasesAndRecollect(
405
+ scopedDb,
406
+ ownerEmail,
407
+ () => collectDocumentSubtreeForDelete(scopedDb, id, ownerEmail),
408
+ );
409
+ return deleteCollectedDocuments(
410
+ scopedDb,
411
+ documentIds,
412
+ ownedDatabaseIds,
413
+ ownerEmail,
414
+ );
415
+ });
327
416
  }
328
417
 
329
418
  async function deleteCollectedDocuments(
@@ -416,6 +505,27 @@ async function deleteCollectedDocuments(
416
505
  );
417
506
  });
418
507
 
508
+ await deleteWhereIn(ownedDatabaseIds, async (databaseIdBatch) => {
509
+ await db
510
+ .delete(schema.contentDatabaseItemKeyClaims)
511
+ .where(
512
+ inArray(
513
+ schema.contentDatabaseItemKeyClaims.databaseId,
514
+ databaseIdBatch,
515
+ ),
516
+ );
517
+ });
518
+ await deleteWhereIn(documentIds, async (documentIdBatch) => {
519
+ await db
520
+ .delete(schema.contentDatabaseItemKeyClaims)
521
+ .where(
522
+ inArray(
523
+ schema.contentDatabaseItemKeyClaims.documentId,
524
+ documentIdBatch,
525
+ ),
526
+ );
527
+ });
528
+
419
529
  await deleteWhereIn(documentIds, async (documentIdBatch) => {
420
530
  await db
421
531
  .delete(schema.contentDatabaseBodyHydrationQueue)
@@ -518,6 +628,20 @@ export async function deleteTrashedDocumentSubtree(
518
628
  db: ReturnType<typeof getDb>,
519
629
  id: string,
520
630
  ownerEmail: string,
631
+ ): Promise<string[]> {
632
+ return db.transaction((tx) =>
633
+ deleteTrashedDocumentSubtreeInTransaction(
634
+ tx as unknown as ReturnType<typeof getDb>,
635
+ id,
636
+ ownerEmail,
637
+ ),
638
+ );
639
+ }
640
+
641
+ async function deleteTrashedDocumentSubtreeInTransaction(
642
+ db: ReturnType<typeof getDb>,
643
+ id: string,
644
+ ownerEmail: string,
521
645
  ): Promise<string[]> {
522
646
  const [root] = await db
523
647
  .select({ id: schema.documents.id })
@@ -537,23 +661,43 @@ export async function deleteTrashedDocumentSubtree(
537
661
  );
538
662
  }
539
663
 
540
- const documentIds = (
541
- await db
542
- .select({ id: schema.documents.id })
543
- .from(schema.documents)
544
- .where(
545
- and(
546
- eq(schema.documents.ownerEmail, ownerEmail),
547
- eq(schema.documents.trashRootId, id),
548
- isNotNull(schema.documents.trashedAt),
549
- ),
550
- )
551
- ).map((document) => document.id);
552
- const ownedDatabaseIds = await selectOwnedDatabaseIds(
664
+ const { documentIds, ownedDatabaseIds } = await lockDatabasesAndRecollect(
553
665
  db,
554
- documentIds,
555
666
  ownerEmail,
556
- ).then((rows) => rows.map((database) => database.id));
667
+ async () => {
668
+ const collectedDocumentIds = (
669
+ await db
670
+ .select({ id: schema.documents.id })
671
+ .from(schema.documents)
672
+ .where(
673
+ and(
674
+ eq(schema.documents.ownerEmail, ownerEmail),
675
+ eq(schema.documents.trashRootId, id),
676
+ isNotNull(schema.documents.trashedAt),
677
+ ),
678
+ )
679
+ ).map((document) => document.id);
680
+ const collectedDatabaseIds = await selectOwnedDatabaseIds(
681
+ db,
682
+ collectedDocumentIds,
683
+ ownerEmail,
684
+ ).then((rows) => rows.map((database) => database.id));
685
+ return {
686
+ documentIds: collectedDocumentIds,
687
+ ownedDatabaseIds: collectedDatabaseIds,
688
+ lockDatabaseIds: [
689
+ ...new Set([
690
+ ...collectedDatabaseIds,
691
+ ...(await selectMembershipDatabaseIds(
692
+ db,
693
+ collectedDocumentIds,
694
+ ownerEmail,
695
+ )),
696
+ ]),
697
+ ],
698
+ };
699
+ },
700
+ );
557
701
 
558
702
  await db
559
703
  .update(schema.documents)
@@ -633,12 +777,10 @@ export default defineAction({
633
777
  if (systemDatabase?.systemRole) {
634
778
  throw new Error("System Content database documents cannot be deleted");
635
779
  }
636
- const deleted = await db.transaction((tx) =>
637
- trashDocumentSubtree(
638
- tx as unknown as ReturnType<typeof getDb>,
639
- id,
640
- existing.ownerEmail as string,
641
- ),
780
+ const deleted = await trashDocumentSubtree(
781
+ db,
782
+ id,
783
+ existing.ownerEmail as string,
642
784
  );
643
785
 
644
786
  await writeAppState("refresh-signal", { ts: Date.now() });