@agent-native/core 0.136.5 → 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 (157) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/app/components/import-menu.tsx +109 -0
  3. package/corpus/templates/clips/app/components/library/library-grid.tsx +8 -18
  4. package/corpus/templates/clips/app/components/library/library-layout.tsx +14 -50
  5. package/corpus/templates/clips/app/components/library/sort-menu.tsx +20 -6
  6. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +10 -34
  7. package/corpus/templates/clips/app/routes/_app.library._index.tsx +0 -2
  8. package/corpus/templates/clips/changelog/2026-08-04-cleaner-library-controls-with-an-icon-only-sort-button.md +6 -0
  9. package/corpus/templates/clips/changelog/2026-08-04-upload-and-loom-imports-now-live-under-one-import-menu.md +6 -0
  10. package/corpus/templates/content/actions/_database-utils.ts +6 -0
  11. package/corpus/templates/content/actions/bind-content-database-source-field.ts +273 -97
  12. package/corpus/templates/content/actions/change-content-database-source-role.ts +65 -29
  13. package/corpus/templates/content/actions/configure-document-property.ts +113 -32
  14. package/corpus/templates/content/actions/delete-content-database.ts +5 -7
  15. package/corpus/templates/content/actions/delete-document-property.ts +109 -62
  16. package/corpus/templates/content/actions/delete-document.ts +178 -36
  17. package/corpus/templates/content/actions/duplicate-database-item.ts +27 -0
  18. package/corpus/templates/content/actions/duplicate-database-items.ts +31 -1
  19. package/corpus/templates/content/actions/permanently-delete-document.ts +4 -6
  20. package/corpus/templates/content/actions/remove-database-items.ts +13 -0
  21. package/corpus/templates/content/actions/set-document-property.ts +69 -1
  22. package/corpus/templates/content/actions/upsert-database-item-by-key.ts +814 -0
  23. package/corpus/templates/content/parity/matrix.md +1 -1
  24. package/corpus/templates/content/parity/matrix.ts +1 -0
  25. package/corpus/templates/content/server/db/schema.ts +27 -0
  26. package/corpus/templates/content/server/plugins/db.ts +24 -0
  27. package/corpus/templates/design/app/components/design/BreakpointBar.tsx +122 -114
  28. package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +2 -2
  29. package/corpus/templates/design/app/components/design/EditPanel.tsx +68 -34
  30. package/corpus/templates/design/app/components/design/KeyboardShortcutsPanel.tsx +78 -4
  31. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +8 -0
  32. package/corpus/templates/design/app/components/design/QuestionFlow.tsx +5 -2
  33. package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +10 -9
  34. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +31 -69
  35. package/corpus/templates/design/app/components/design/inspector/design-icons.tsx +80 -106
  36. package/corpus/templates/design/app/components/design/multi-screen/types.ts +4 -0
  37. package/corpus/templates/design/app/hooks/use-editor-preferences.ts +75 -0
  38. package/corpus/templates/design/app/i18n-data.ts +8 -0
  39. package/corpus/templates/design/app/i18n-keyboard-shortcuts.ts +61 -0
  40. package/corpus/templates/design/app/pages/design-editor/editor-preferences.ts +98 -0
  41. package/corpus/templates/design/app/pages/design-editor/nudge-intent.ts +491 -0
  42. package/corpus/templates/design/app/pages/design-editor/paste-placement.ts +133 -0
  43. package/corpus/templates/design/app/pages/design-editor/png-export-render.ts +8 -0
  44. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +5 -12
  45. package/corpus/templates/design/changelog/2026-08-03-arrow-keys-now-reorder-a-layer-inside-an-auto-layout-frame.md +6 -0
  46. package/corpus/templates/design/changelog/2026-08-03-auto-layout-flow-icons-show-the-direction-at-a-glance-instea.md +6 -0
  47. package/corpus/templates/design/changelog/2026-08-03-auto-layout-icons-share-one-outlined-square-style-so-flow-ga.md +6 -0
  48. package/corpus/templates/design/changelog/2026-08-03-export-has-a-named-preview-section-you-can-expand-replacing-.md +6 -0
  49. package/corpus/templates/design/changelog/2026-08-03-export-preview-shows-the-selected-element-instead-of-the-who.md +6 -0
  50. package/corpus/templates/design/changelog/2026-08-03-inspector-sections-show-an-expand-indicator-so-it-is-clear-w.md +6 -0
  51. package/corpus/templates/design/changelog/2026-08-03-paste-lands-inside-a-selected-frame-instead-of-beside-it.md +6 -0
  52. package/corpus/templates/design/changelog/2026-08-03-the-add-breakpoint-button-sits-outside-the-breakpoint-select.md +6 -0
  53. package/corpus/templates/design/changelog/2026-08-03-the-agent-s-follow-up-questions-keep-space-below-the-continu.md +6 -0
  54. package/corpus/templates/design/changelog/2026-08-03-the-agent-s-follow-up-questions-share-the-chat-panel-s-backg.md +6 -0
  55. package/corpus/templates/design/changelog/2026-08-03-the-export-panel-no-longer-stacks-a-duplicate-export-block-o.md +6 -0
  56. package/corpus/templates/design/changelog/2026-08-03-the-text-tool-uses-a-t-icon-matching-the-letter-it-inserts.md +6 -0
  57. package/corpus/templates/factory/.env.example +17 -0
  58. package/corpus/templates/factory/AGENTS.md +27 -17
  59. package/corpus/templates/factory/README.md +19 -9
  60. package/corpus/templates/factory/actions/get-slack-feedback-context.ts +90 -0
  61. package/corpus/templates/factory/actions/get-triage-config.ts +14 -3
  62. package/corpus/templates/factory/actions/govern-agent-native-pull-request.ts +386 -0
  63. package/corpus/templates/factory/actions/list-factory-automations.ts +53 -0
  64. package/corpus/templates/factory/actions/list-triage-items.ts +7 -2
  65. package/corpus/templates/factory/actions/poll-github-sources.ts +203 -0
  66. package/corpus/templates/factory/actions/poll-sentry-errors.ts +161 -0
  67. package/corpus/templates/factory/actions/poll-slack-channel.ts +4 -2
  68. package/corpus/templates/factory/actions/run-factory-automation.ts +42 -0
  69. package/corpus/templates/factory/actions/save-factory-automation.ts +69 -0
  70. package/corpus/templates/factory/actions/save-triage-config.ts +28 -2
  71. package/corpus/templates/factory/actions/start-builder-for-item.ts +411 -0
  72. package/corpus/templates/factory/app/components/factory/FactoryCanvas.tsx +14 -3
  73. package/corpus/templates/factory/app/components/ui/checkbox.tsx +1 -0
  74. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +7 -0
  75. package/corpus/templates/factory/app/i18n/en-US.ts +35 -1
  76. package/corpus/templates/factory/app/routes/factory.tsx +462 -9
  77. package/corpus/templates/factory/changelog/2026-08-04-added-organization-owned-factory-automations-for-slack-bug-t.md +6 -0
  78. package/corpus/templates/factory/changelog/2026-08-04-automation-editor-controls-and-model-picker.md +6 -0
  79. package/corpus/templates/factory/netlify.toml +2 -2
  80. package/corpus/templates/factory/server/connectors/credentials.ts +6 -5
  81. package/corpus/templates/factory/server/connectors/slack.ts +133 -5
  82. package/corpus/templates/factory/server/db/schema.ts +6 -0
  83. package/corpus/templates/factory/server/lib/require-factory-automation.ts +65 -0
  84. package/corpus/templates/factory/server/plugins/agent-chat.ts +29 -11
  85. package/corpus/templates/factory/server/plugins/auth.ts +1 -0
  86. package/corpus/templates/factory/server/plugins/factory-migrations.ts +12 -0
  87. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +218 -67
  88. package/corpus/templates/factory/server/triage/builder-executor.ts +4 -0
  89. package/corpus/templates/factory/server/triage/contracts.ts +14 -1
  90. package/corpus/templates/factory/server/triage/github-client.ts +404 -0
  91. package/corpus/templates/factory/server/triage/metadata.ts +44 -0
  92. package/corpus/templates/factory/server/triage/pr-babysit.ts +5 -5
  93. package/corpus/templates/factory/server/triage/pr-policy.ts +144 -0
  94. package/corpus/templates/factory/server/triage/sentry-client.ts +180 -0
  95. package/corpus/templates/factory/server/triage/slack-client.ts +43 -0
  96. package/corpus/templates/factory/server/triage/slack-poller.ts +9 -2
  97. package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +27 -0
  98. package/corpus/templates/plan/.agents/skills/visual-plan/references/canvas.md +14 -4
  99. package/corpus/templates/plan/.agents/skills/visual-plan/references/wireframe.md +7 -0
  100. package/corpus/templates/plan/.agents/skills/visual-recap/references/wireframe.md +7 -0
  101. package/corpus/templates/plan/actions/create-plan-design.ts +8 -6
  102. package/corpus/templates/plan/actions/create-prototype-plan.ts +8 -6
  103. package/corpus/templates/plan/actions/create-ui-plan.ts +7 -7
  104. package/corpus/templates/plan/actions/get-plan-blocks.ts +7 -1
  105. package/corpus/templates/plan/actions/validate-plan-input.ts +39 -0
  106. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +167 -11
  107. package/corpus/templates/plan/app/i18n/en-US.ts +1 -0
  108. package/corpus/templates/plan/changelog/2026-08-04-canvas-zoom-controls-now-explain-the-command-ctrl-plus-scrol.md +6 -0
  109. package/corpus/templates/plan/changelog/2026-08-04-pinch-to-zoom-is-now-supported-on-touchscreens-in-visual-pla.md +6 -0
  110. package/corpus/templates/plan/shared/plan-content.ts +17 -2
  111. package/dist/cli/skills-content/canvas.d.ts +3 -3
  112. package/dist/cli/skills-content/canvas.js +16 -5
  113. package/dist/cli/skills-content/visual-plan-skill.d.ts +1 -1
  114. package/dist/cli/skills-content/visual-plan-skill.js +27 -0
  115. package/dist/cli/skills-content/wireframe.d.ts +2 -2
  116. package/dist/cli/skills-content/wireframe.js +7 -0
  117. package/dist/client/AgentPanel.js +23 -8
  118. package/dist/client/api-path.d.ts +6 -0
  119. package/dist/client/api-path.js +55 -0
  120. package/dist/client/onboarding/FirstRunOnboarding.d.ts +3 -0
  121. package/dist/client/onboarding/FirstRunOnboarding.js +93 -0
  122. package/dist/client/onboarding/first-run-enabled.d.ts +2 -0
  123. package/dist/client/onboarding/first-run-enabled.js +9 -0
  124. package/dist/client/onboarding/index.d.ts +2 -1
  125. package/dist/client/onboarding/index.js +1 -0
  126. package/dist/client/onboarding/use-onboarding.d.ts +6 -1
  127. package/dist/client/onboarding/use-onboarding.js +32 -1
  128. package/dist/client/route-state.js +10 -0
  129. package/dist/connections/catalog.d.ts +4 -4
  130. package/dist/connections/catalog.js +3 -3
  131. package/dist/db/index.d.ts +1 -1
  132. package/dist/db/index.js +1 -1
  133. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  134. package/dist/notifications/routes.d.ts +5 -5
  135. package/dist/observability/routes.d.ts +3 -3
  136. package/dist/onboarding/app-profile.d.ts +4 -0
  137. package/dist/onboarding/app-profile.js +378 -0
  138. package/dist/onboarding/index.d.ts +2 -1
  139. package/dist/onboarding/index.js +1 -0
  140. package/dist/onboarding/plugin.d.ts +2 -0
  141. package/dist/onboarding/plugin.js +21 -1
  142. package/dist/onboarding/types.d.ts +19 -0
  143. package/dist/progress/routes.d.ts +1 -1
  144. package/dist/provider-api/actions/custom-provider-registration.d.ts +9 -9
  145. package/dist/provider-api/actions/provider-api.d.ts +12 -12
  146. package/dist/resources/handlers.d.ts +1 -1
  147. package/dist/server/auth.js +21 -1
  148. package/dist/server/better-auth-instance.d.ts +3 -2
  149. package/dist/server/better-auth-instance.js +9 -7
  150. package/dist/server/email-markdown.js +3 -8
  151. package/dist/server/transcribe-voice.d.ts +1 -1
  152. package/dist/shared/first-run-onboarding.d.ts +3 -0
  153. package/dist/shared/first-run-onboarding.js +3 -0
  154. package/dist/triggers/index.d.ts +3 -0
  155. package/dist/triggers/index.js +5 -0
  156. package/docs/content/cross-app-sso.mdx +26 -0
  157. package/package.json +1 -1
@@ -0,0 +1,814 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { writeAppState } from "@agent-native/core/application-state";
3
+ import {
4
+ createDbExec,
5
+ getDatabaseUrl,
6
+ isLocalDatabase,
7
+ isPostgres,
8
+ } from "@agent-native/core/db";
9
+ import { getRequestUserEmail } from "@agent-native/core/server/request-context";
10
+ import { assertAccess } from "@agent-native/core/sharing";
11
+ import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm";
12
+ import { z } from "zod";
13
+
14
+ import { getDb, schema } from "../server/db/index.js";
15
+ import {
16
+ isBlocksPropertyType,
17
+ isComputedPropertyType,
18
+ type DocumentPropertyType,
19
+ } from "../shared/properties.js";
20
+ import { ensureDocumentFilesMembership } from "./_content-files.js";
21
+ import { getContentDatabaseResponse } from "./_database-utils.js";
22
+ import {
23
+ databaseItemsPositionScope,
24
+ documentsPositionScope,
25
+ withPositionLock,
26
+ } from "./_position-utils.js";
27
+ import { nanoid, normalizedValueJson } from "./_property-utils.js";
28
+ import getDocument from "./get-document.js";
29
+
30
+ const upsertSchema = z.object({
31
+ databaseId: z.string().min(1).describe("Target Content database ID"),
32
+ keyPropertyId: z
33
+ .string()
34
+ .min(1)
35
+ .describe("Database property definition ID used as the stable key"),
36
+ keyValue: z.string().min(1).describe("Non-empty stable key value"),
37
+ title: z
38
+ .string()
39
+ .max(500)
40
+ .optional()
41
+ .describe("Row title to create or update"),
42
+ body: z.string().optional().describe("Row body to create or update"),
43
+ propertyValues: z
44
+ .record(z.string(), z.unknown())
45
+ .optional()
46
+ .describe("Property values keyed by property definition ID"),
47
+ });
48
+
49
+ type Identity = { itemId: string; documentId: string };
50
+
51
+ async function withStableKeyReadbackLock<T>(
52
+ scope: string,
53
+ run: () => Promise<T>,
54
+ ): Promise<T> {
55
+ const runInProcess = () =>
56
+ withPositionLock(`stableKeyReadback:${scope}`, run);
57
+
58
+ // Every dialect needs one lock that spans both the write transaction and
59
+ // the exact post-commit readback; otherwise a later writer can legitimately
60
+ // overtake the first request between those phases and turn a committed
61
+ // mutation into an ambiguous 500 receipt. Real PostgreSQL workers also need
62
+ // the advisory lock because their in-process promise chains are independent.
63
+ if (!isPostgres() || isLocalDatabase()) return runInProcess();
64
+
65
+ // Never hold an advisory lock in the ordinary shared application pool: a
66
+ // small burst could occupy every connection while the lock winner still
67
+ // needs that pool to perform its write and readback. One process-wide gate
68
+ // bounds this action to one disposable lock connection per process while
69
+ // PostgreSQL coordinates the same scope across independent processes.
70
+ return withPositionLock("stableKeyReadback:postgres-connection", async () => {
71
+ const lockDb = await createDbExec({ url: getDatabaseUrl() });
72
+ try {
73
+ if (!lockDb.transaction) {
74
+ throw new Error("PostgreSQL stable-key locking requires transactions.");
75
+ }
76
+ return await lockDb.transaction(async (tx) => {
77
+ await tx.execute({
78
+ sql: "SELECT pg_advisory_xact_lock(hashtextextended(?, 0))",
79
+ args: [scope],
80
+ });
81
+ return runInProcess();
82
+ });
83
+ } finally {
84
+ await lockDb.close?.();
85
+ }
86
+ });
87
+ }
88
+
89
+ export default defineAction({
90
+ description:
91
+ "Atomically create or update one Content database row by a database-scoped stable property key. Returns a created, updated, or unchanged receipt with stable item and document IDs.",
92
+ schema: upsertSchema,
93
+ run: async ({
94
+ databaseId,
95
+ keyPropertyId,
96
+ keyValue,
97
+ title,
98
+ body,
99
+ propertyValues,
100
+ }) => {
101
+ const db = getDb();
102
+ const [database] = await db
103
+ .select()
104
+ .from(schema.contentDatabases)
105
+ .where(
106
+ and(
107
+ eq(schema.contentDatabases.id, databaseId),
108
+ isNull(schema.contentDatabases.deletedAt),
109
+ ),
110
+ );
111
+ if (!database) throw new Error(`Database "${databaseId}" not found.`);
112
+ if (database.systemRole) {
113
+ throw new Error(
114
+ "Stable-key upserts are supported only for ordinary Content databases, not system databases.",
115
+ );
116
+ }
117
+
118
+ const access = await assertAccess(
119
+ "document",
120
+ database.documentId,
121
+ "editor",
122
+ );
123
+ const databaseDocument = access.resource;
124
+ const databaseSpaceId = database.spaceId ?? databaseDocument.spaceId;
125
+ if (!databaseSpaceId)
126
+ throw new Error("Database does not belong to a Content space.");
127
+ if (
128
+ database.spaceId &&
129
+ databaseDocument.spaceId &&
130
+ database.spaceId !== databaseDocument.spaceId
131
+ ) {
132
+ throw new Error(
133
+ `Database "${databaseId}" has inconsistent Content space.`,
134
+ );
135
+ }
136
+
137
+ const definitions = await db
138
+ .select()
139
+ .from(schema.documentPropertyDefinitions)
140
+ .where(
141
+ and(
142
+ eq(schema.documentPropertyDefinitions.databaseId, databaseId),
143
+ eq(
144
+ schema.documentPropertyDefinitions.ownerEmail,
145
+ database.ownerEmail,
146
+ ),
147
+ ),
148
+ );
149
+ const definitionsById = new Map(
150
+ definitions.map((definition) => [definition.id, definition]),
151
+ );
152
+ const keyDefinition = definitionsById.get(keyPropertyId);
153
+ if (!keyDefinition)
154
+ throw new Error(
155
+ `Key property "${keyPropertyId}" does not belong to database "${databaseId}".`,
156
+ );
157
+ const keyType = keyDefinition.type as DocumentPropertyType;
158
+ const [sourceManagedKey] = await db
159
+ .select({ id: schema.contentDatabaseSourceFields.id })
160
+ .from(schema.contentDatabaseSourceFields)
161
+ .innerJoin(
162
+ schema.contentDatabaseSources,
163
+ eq(
164
+ schema.contentDatabaseSources.id,
165
+ schema.contentDatabaseSourceFields.sourceId,
166
+ ),
167
+ )
168
+ .where(
169
+ and(
170
+ eq(schema.contentDatabaseSources.databaseId, databaseId),
171
+ eq(schema.contentDatabaseSourceFields.propertyId, keyPropertyId),
172
+ ),
173
+ )
174
+ .limit(1);
175
+ if (
176
+ keyDefinition.systemRole ||
177
+ sourceManagedKey ||
178
+ isComputedPropertyType(keyType) ||
179
+ isBlocksPropertyType(keyType)
180
+ ) {
181
+ throw new Error(
182
+ `Property "${keyDefinition.name}" cannot be used as a stable key.`,
183
+ );
184
+ }
185
+ const keyValueJson = normalizedValueJson(keyType, keyValue);
186
+ if (keyValueJson === "null" || keyValueJson === '\"\"')
187
+ throw new Error("Stable key value must normalize to a non-empty value.");
188
+
189
+ // prettier-ignore
190
+ return withStableKeyReadbackLock(
191
+ JSON.stringify([databaseId, keyPropertyId, keyValueJson]),
192
+ async () => {
193
+ const values = new Map<string, string>();
194
+ for (const [propertyId, value] of Object.entries(propertyValues ?? {})) {
195
+ const definition = definitionsById.get(propertyId);
196
+ if (!definition)
197
+ throw new Error(
198
+ `Property "${propertyId}" does not belong to database "${databaseId}".`,
199
+ );
200
+ const type = definition.type as DocumentPropertyType;
201
+ if (
202
+ definition.systemRole ||
203
+ isComputedPropertyType(type) ||
204
+ isBlocksPropertyType(type)
205
+ ) {
206
+ throw new Error(
207
+ `Property "${definition.name}" cannot be written by this action.`,
208
+ );
209
+ }
210
+ const valueJson = normalizedValueJson(type, value);
211
+ if (propertyId === keyPropertyId && valueJson !== keyValueJson) {
212
+ throw new Error(
213
+ "propertyValues must not change keyPropertyId away from keyValue.",
214
+ );
215
+ }
216
+ values.set(propertyId, valueJson);
217
+ }
218
+ values.set(keyPropertyId, keyValueJson);
219
+
220
+ const now = new Date().toISOString();
221
+ const proposed: Identity = { itemId: nanoid(), documentId: nanoid() };
222
+ const inheritedShares = await db
223
+ .select({
224
+ principalType: schema.documentShares.principalType,
225
+ principalId: schema.documentShares.principalId,
226
+ role: schema.documentShares.role,
227
+ })
228
+ .from(schema.documentShares)
229
+ .where(eq(schema.documentShares.resourceId, database.documentId));
230
+
231
+ const result = await withPositionLock(
232
+ documentsPositionScope(database.ownerEmail, database.documentId),
233
+ () =>
234
+ withPositionLock(databaseItemsPositionScope(databaseId), () =>
235
+ db.transaction(async (tx) => {
236
+ // Serialize against trash/permanent-delete transactions and
237
+ // revalidate the exact database after acquiring the row lock. A
238
+ // pre-transaction access check alone can become stale while an
239
+ // unattended projection is waiting to write.
240
+ const [lockedDatabase] = await tx
241
+ .update(schema.contentDatabases)
242
+ .set({
243
+ updatedAt: sql`${schema.contentDatabases.updatedAt}`,
244
+ })
245
+ .where(
246
+ and(
247
+ eq(schema.contentDatabases.id, databaseId),
248
+ eq(schema.contentDatabases.documentId, database.documentId),
249
+ eq(schema.contentDatabases.ownerEmail, database.ownerEmail),
250
+ isNull(schema.contentDatabases.deletedAt),
251
+ ),
252
+ )
253
+ .returning({
254
+ id: schema.contentDatabases.id,
255
+ systemRole: schema.contentDatabases.systemRole,
256
+ });
257
+ if (!lockedDatabase || lockedDatabase.systemRole) {
258
+ throw new Error(
259
+ `Database "${databaseId}" is no longer an active ordinary Content database.`,
260
+ );
261
+ }
262
+
263
+ // Lock and revalidate every requested definition after the database
264
+ // lock. Property deletion/configuration touches these same rows, so
265
+ // either it commits first and this fails closed, or it waits until
266
+ // the upsert has committed a self-consistent claim/value set.
267
+ const requestedPropertyIds = [...values.keys()];
268
+ const lockedDefinitions = await tx
269
+ .update(schema.documentPropertyDefinitions)
270
+ .set({
271
+ updatedAt: sql`${schema.documentPropertyDefinitions.updatedAt}`,
272
+ })
273
+ .where(
274
+ and(
275
+ eq(schema.documentPropertyDefinitions.databaseId, databaseId),
276
+ eq(
277
+ schema.documentPropertyDefinitions.ownerEmail,
278
+ database.ownerEmail,
279
+ ),
280
+ inArray(
281
+ schema.documentPropertyDefinitions.id,
282
+ requestedPropertyIds,
283
+ ),
284
+ ),
285
+ )
286
+ .returning({
287
+ id: schema.documentPropertyDefinitions.id,
288
+ type: schema.documentPropertyDefinitions.type,
289
+ systemRole: schema.documentPropertyDefinitions.systemRole,
290
+ });
291
+ const lockedDefinitionsById = new Map(
292
+ lockedDefinitions.map((definition) => [
293
+ definition.id,
294
+ definition,
295
+ ]),
296
+ );
297
+ for (const propertyId of requestedPropertyIds) {
298
+ const initialDefinition = definitionsById.get(propertyId);
299
+ const lockedDefinition = lockedDefinitionsById.get(propertyId);
300
+ if (
301
+ !initialDefinition ||
302
+ !lockedDefinition ||
303
+ lockedDefinition.type !== initialDefinition.type ||
304
+ lockedDefinition.systemRole !== initialDefinition.systemRole
305
+ ) {
306
+ throw new Error(
307
+ `Property "${propertyId}" changed or was deleted before the stable-key upsert could write.`,
308
+ );
309
+ }
310
+ const lockedType = lockedDefinition.type as DocumentPropertyType;
311
+ if (
312
+ lockedDefinition.systemRole ||
313
+ isComputedPropertyType(lockedType) ||
314
+ isBlocksPropertyType(lockedType)
315
+ ) {
316
+ throw new Error(
317
+ `Property "${propertyId}" cannot be written by this action.`,
318
+ );
319
+ }
320
+ }
321
+
322
+ const transactionSourceManagedProperties = await tx
323
+ .select({
324
+ propertyId: schema.contentDatabaseSourceFields.propertyId,
325
+ })
326
+ .from(schema.contentDatabaseSourceFields)
327
+ .innerJoin(
328
+ schema.contentDatabaseSources,
329
+ eq(
330
+ schema.contentDatabaseSources.id,
331
+ schema.contentDatabaseSourceFields.sourceId,
332
+ ),
333
+ )
334
+ .where(
335
+ and(
336
+ eq(schema.contentDatabaseSources.databaseId, databaseId),
337
+ inArray(
338
+ schema.contentDatabaseSourceFields.propertyId,
339
+ requestedPropertyIds,
340
+ ),
341
+ ),
342
+ );
343
+ if (transactionSourceManagedProperties.length > 0) {
344
+ const managedPropertyId =
345
+ transactionSourceManagedProperties[0].propertyId;
346
+ const managedDefinition = managedPropertyId
347
+ ? definitionsById.get(managedPropertyId)
348
+ : undefined;
349
+ if (managedPropertyId === keyPropertyId) {
350
+ throw new Error(
351
+ `Property "${keyDefinition.name}" cannot be used as a stable key.`,
352
+ );
353
+ }
354
+ throw new Error(
355
+ `Property "${managedDefinition?.name ?? managedPropertyId}" is source-managed and cannot be written by this action.`,
356
+ );
357
+ }
358
+
359
+ const matches = await tx
360
+ .select({
361
+ itemId: schema.contentDatabaseItems.id,
362
+ documentId: schema.contentDatabaseItems.documentId,
363
+ trashedAt: schema.documents.trashedAt,
364
+ })
365
+ .from(schema.documentPropertyValues)
366
+ .innerJoin(
367
+ schema.contentDatabaseItems,
368
+ eq(
369
+ schema.contentDatabaseItems.documentId,
370
+ schema.documentPropertyValues.documentId,
371
+ ),
372
+ )
373
+ .innerJoin(
374
+ schema.documents,
375
+ eq(schema.documents.id, schema.contentDatabaseItems.documentId),
376
+ )
377
+ .where(
378
+ and(
379
+ eq(schema.contentDatabaseItems.databaseId, databaseId),
380
+ eq(schema.documentPropertyValues.propertyId, keyPropertyId),
381
+ eq(schema.documentPropertyValues.valueJson, keyValueJson),
382
+ ),
383
+ );
384
+ const matchByDocument = new Map(
385
+ matches.map((row) => [row.documentId, row]),
386
+ );
387
+ if (matches.some((row) => row.trashedAt))
388
+ throw new Error(
389
+ "Stable key belongs to a trashed database row; restore or resolve it before upserting.",
390
+ );
391
+ if (
392
+ matches.length !== matchByDocument.size ||
393
+ matchByDocument.size > 1
394
+ ) {
395
+ throw new Error(
396
+ "Stable key matches multiple database rows; reconcile the duplicates before upserting.",
397
+ );
398
+ }
399
+
400
+ const [claim] = await tx
401
+ .select()
402
+ .from(schema.contentDatabaseItemKeyClaims)
403
+ .where(
404
+ and(
405
+ eq(
406
+ schema.contentDatabaseItemKeyClaims.databaseId,
407
+ databaseId,
408
+ ),
409
+ eq(
410
+ schema.contentDatabaseItemKeyClaims.propertyId,
411
+ keyPropertyId,
412
+ ),
413
+ eq(
414
+ schema.contentDatabaseItemKeyClaims.keyValueJson,
415
+ keyValueJson,
416
+ ),
417
+ ),
418
+ );
419
+ let identity: Identity | undefined = claim && {
420
+ itemId: claim.itemId,
421
+ documentId: claim.documentId,
422
+ };
423
+ const matched = [...matchByDocument.values()][0];
424
+ if (
425
+ claim &&
426
+ matched &&
427
+ (claim.itemId !== matched.itemId ||
428
+ claim.documentId !== matched.documentId)
429
+ ) {
430
+ throw new Error(
431
+ "Stable key claim conflicts with the stored property value; reconcile before upserting.",
432
+ );
433
+ }
434
+ if (claim && !matched) {
435
+ throw new Error(
436
+ "Stable key claim no longer matches the stored key property; reconcile before upserting.",
437
+ );
438
+ }
439
+
440
+ if (!identity && matched) {
441
+ const candidate = {
442
+ itemId: matched.itemId,
443
+ documentId: matched.documentId,
444
+ };
445
+ await tx
446
+ .insert(schema.contentDatabaseItemKeyClaims)
447
+ .values({
448
+ id: nanoid(),
449
+ ownerEmail: database.ownerEmail,
450
+ orgId: database.orgId,
451
+ databaseId,
452
+ propertyId: keyPropertyId,
453
+ keyValueJson,
454
+ ...candidate,
455
+ createdAt: now,
456
+ updatedAt: now,
457
+ })
458
+ .onConflictDoNothing();
459
+ const [reloaded] = await tx
460
+ .select()
461
+ .from(schema.contentDatabaseItemKeyClaims)
462
+ .where(
463
+ and(
464
+ eq(
465
+ schema.contentDatabaseItemKeyClaims.databaseId,
466
+ databaseId,
467
+ ),
468
+ eq(
469
+ schema.contentDatabaseItemKeyClaims.propertyId,
470
+ keyPropertyId,
471
+ ),
472
+ eq(
473
+ schema.contentDatabaseItemKeyClaims.keyValueJson,
474
+ keyValueJson,
475
+ ),
476
+ ),
477
+ );
478
+ if (
479
+ !reloaded ||
480
+ reloaded.itemId !== candidate.itemId ||
481
+ reloaded.documentId !== candidate.documentId
482
+ ) {
483
+ throw new Error(
484
+ "Stable key was claimed by a different row; reconcile before upserting.",
485
+ );
486
+ }
487
+ identity = candidate;
488
+ }
489
+
490
+ if (!identity) {
491
+ await tx
492
+ .insert(schema.contentDatabaseItemKeyClaims)
493
+ .values({
494
+ id: nanoid(),
495
+ ownerEmail: database.ownerEmail,
496
+ orgId: database.orgId,
497
+ databaseId,
498
+ propertyId: keyPropertyId,
499
+ keyValueJson,
500
+ ...proposed,
501
+ createdAt: now,
502
+ updatedAt: now,
503
+ })
504
+ .onConflictDoNothing();
505
+ const [reloaded] = await tx
506
+ .select()
507
+ .from(schema.contentDatabaseItemKeyClaims)
508
+ .where(
509
+ and(
510
+ eq(
511
+ schema.contentDatabaseItemKeyClaims.databaseId,
512
+ databaseId,
513
+ ),
514
+ eq(
515
+ schema.contentDatabaseItemKeyClaims.propertyId,
516
+ keyPropertyId,
517
+ ),
518
+ eq(
519
+ schema.contentDatabaseItemKeyClaims.keyValueJson,
520
+ keyValueJson,
521
+ ),
522
+ ),
523
+ );
524
+ if (!reloaded)
525
+ throw new Error("Stable key claim could not be read back.");
526
+ identity = {
527
+ itemId: reloaded.itemId,
528
+ documentId: reloaded.documentId,
529
+ };
530
+ if (
531
+ identity.itemId === proposed.itemId &&
532
+ identity.documentId === proposed.documentId
533
+ ) {
534
+ const [maxDoc] = await tx
535
+ .select({ max: sql<number>`COALESCE(MAX(position), -1)` })
536
+ .from(schema.documents)
537
+ .where(
538
+ and(
539
+ eq(schema.documents.ownerEmail, database.ownerEmail),
540
+ eq(schema.documents.parentId, database.documentId),
541
+ ),
542
+ );
543
+ const [maxItem] = await tx
544
+ .select({ max: sql<number>`COALESCE(MAX(position), -1)` })
545
+ .from(schema.contentDatabaseItems)
546
+ .where(
547
+ eq(schema.contentDatabaseItems.databaseId, databaseId),
548
+ );
549
+ await tx.insert(schema.documents).values({
550
+ id: proposed.documentId,
551
+ spaceId: databaseSpaceId,
552
+ ownerEmail: database.ownerEmail,
553
+ orgId: database.orgId,
554
+ parentId: database.documentId,
555
+ title: title?.trim() ?? "",
556
+ content: body ?? "",
557
+ icon: null,
558
+ position: (maxDoc?.max ?? -1) + 1,
559
+ isFavorite: 0,
560
+ hideFromSearch: databaseDocument.hideFromSearch ?? 0,
561
+ visibility: databaseDocument.visibility ?? "private",
562
+ createdAt: now,
563
+ updatedAt: now,
564
+ });
565
+ await tx.insert(schema.contentDatabaseItems).values({
566
+ id: proposed.itemId,
567
+ ownerEmail: database.ownerEmail,
568
+ orgId: database.orgId,
569
+ databaseId,
570
+ documentId: proposed.documentId,
571
+ position: (maxItem?.max ?? -1) + 1,
572
+ createdAt: now,
573
+ updatedAt: now,
574
+ });
575
+ await tx.insert(schema.documentPropertyValues).values(
576
+ [...values.entries()].map(([propertyId, valueJson]) => ({
577
+ id: nanoid(),
578
+ ownerEmail: database.ownerEmail,
579
+ documentId: proposed.documentId,
580
+ propertyId,
581
+ valueJson,
582
+ createdAt: now,
583
+ updatedAt: now,
584
+ })),
585
+ );
586
+ if (inheritedShares.length > 0)
587
+ await tx.insert(schema.documentShares).values(
588
+ inheritedShares.map((share) => ({
589
+ id: nanoid(),
590
+ resourceId: proposed.documentId,
591
+ principalType: share.principalType,
592
+ principalId: share.principalId,
593
+ role: share.role,
594
+ createdBy: getRequestUserEmail() ?? database.ownerEmail,
595
+ createdAt: now,
596
+ })),
597
+ );
598
+ await ensureDocumentFilesMembership(
599
+ tx,
600
+ proposed.documentId,
601
+ now,
602
+ );
603
+ return { status: "created" as const, ...identity };
604
+ }
605
+ }
606
+
607
+ // Serialize every stable-key update for this canonical row at the
608
+ // database layer. The in-process position lock cannot protect two
609
+ // serverless/PostgreSQL workers, while this no-op UPDATE takes the
610
+ // membership row lock until the surrounding transaction commits.
611
+ // Re-read property values only after acquiring it so two workers
612
+ // cannot both observe a missing value and insert duplicates.
613
+ await tx
614
+ .update(schema.contentDatabaseItems)
615
+ .set({
616
+ updatedAt: sql`${schema.contentDatabaseItems.updatedAt}`,
617
+ })
618
+ .where(
619
+ and(
620
+ eq(schema.contentDatabaseItems.id, identity.itemId),
621
+ eq(schema.contentDatabaseItems.databaseId, databaseId),
622
+ eq(
623
+ schema.contentDatabaseItems.documentId,
624
+ identity.documentId,
625
+ ),
626
+ ),
627
+ );
628
+ const [claimedMembership] = await tx
629
+ .select({
630
+ itemId: schema.contentDatabaseItems.id,
631
+ documentId: schema.contentDatabaseItems.documentId,
632
+ })
633
+ .from(schema.contentDatabaseItems)
634
+ .where(
635
+ and(
636
+ eq(schema.contentDatabaseItems.id, identity.itemId),
637
+ eq(schema.contentDatabaseItems.databaseId, databaseId),
638
+ eq(
639
+ schema.contentDatabaseItems.documentId,
640
+ identity.documentId,
641
+ ),
642
+ ),
643
+ );
644
+ if (!claimedMembership) {
645
+ throw new Error(
646
+ "Stable key claim does not resolve to a row in this database.",
647
+ );
648
+ }
649
+ await assertAccess("document", identity.documentId, "editor");
650
+ // Serialize full-document writes with SQL-backed editor saves. The
651
+ // Content editor reconciles genuinely newer SQL snapshots into the
652
+ // live Y.Doc; taking this row lock and advancing updatedAt
653
+ // monotonically prevents a stale open editor from later winning.
654
+ const [document] = await tx
655
+ .update(schema.documents)
656
+ .set({ updatedAt: sql`${schema.documents.updatedAt}` })
657
+ .where(
658
+ and(
659
+ eq(schema.documents.id, identity.documentId),
660
+ isNull(schema.documents.trashedAt),
661
+ ),
662
+ )
663
+ .returning();
664
+ if (!document || document.trashedAt)
665
+ throw new Error(
666
+ "Stable key claim does not resolve to an active document.",
667
+ );
668
+ const existingValues = await tx
669
+ .select()
670
+ .from(schema.documentPropertyValues)
671
+ .where(
672
+ and(
673
+ eq(
674
+ schema.documentPropertyValues.documentId,
675
+ identity.documentId,
676
+ ),
677
+ inArray(schema.documentPropertyValues.propertyId, [
678
+ ...values.keys(),
679
+ ]),
680
+ ),
681
+ );
682
+ const existingByProperty = new Map(
683
+ existingValues.map((value) => [value.propertyId, value]),
684
+ );
685
+ if (existingValues.length !== existingByProperty.size)
686
+ throw new Error(
687
+ "Target row has duplicate property values; reconcile before upserting.",
688
+ );
689
+ const changedValues = [...values.entries()].filter(
690
+ ([propertyId, valueJson]) =>
691
+ existingByProperty.get(propertyId)?.valueJson !== valueJson,
692
+ );
693
+ const documentChanged =
694
+ (title !== undefined && document.title !== title.trim()) ||
695
+ (body !== undefined && document.content !== body);
696
+ if (!documentChanged && changedValues.length === 0)
697
+ return { status: "unchanged" as const, ...identity };
698
+ if (documentChanged) {
699
+ const mutationTime = new Date().toISOString();
700
+ const updatedAt =
701
+ mutationTime > document.updatedAt
702
+ ? mutationTime
703
+ : new Date(
704
+ new Date(document.updatedAt).getTime() + 1,
705
+ ).toISOString();
706
+ await tx
707
+ .update(schema.documents)
708
+ .set({
709
+ ...(title !== undefined ? { title: title.trim() } : {}),
710
+ ...(body !== undefined ? { content: body } : {}),
711
+ updatedAt,
712
+ })
713
+ .where(eq(schema.documents.id, identity.documentId));
714
+ }
715
+ for (const [propertyId, valueJson] of changedValues) {
716
+ const existing = existingByProperty.get(propertyId);
717
+ if (existing)
718
+ await tx
719
+ .update(schema.documentPropertyValues)
720
+ .set({ valueJson, updatedAt: now })
721
+ .where(eq(schema.documentPropertyValues.id, existing.id));
722
+ else
723
+ await tx.insert(schema.documentPropertyValues).values({
724
+ id: nanoid(),
725
+ ownerEmail: database.ownerEmail,
726
+ documentId: identity.documentId,
727
+ propertyId,
728
+ valueJson,
729
+ createdAt: now,
730
+ updatedAt: now,
731
+ });
732
+ }
733
+ await tx
734
+ .delete(schema.contentDatabaseItemKeyClaims)
735
+ .where(
736
+ and(
737
+ eq(
738
+ schema.contentDatabaseItemKeyClaims.databaseId,
739
+ databaseId,
740
+ ),
741
+ eq(
742
+ schema.contentDatabaseItemKeyClaims.propertyId,
743
+ keyPropertyId,
744
+ ),
745
+ eq(
746
+ schema.contentDatabaseItemKeyClaims.documentId,
747
+ identity.documentId,
748
+ ),
749
+ ne(
750
+ schema.contentDatabaseItemKeyClaims.keyValueJson,
751
+ keyValueJson,
752
+ ),
753
+ ),
754
+ );
755
+ return { status: "updated" as const, ...identity };
756
+ }),
757
+ ),
758
+ );
759
+
760
+ await writeAppState("refresh-signal", { ts: Date.now() }).catch(() => {});
761
+ const readback = await getContentDatabaseResponse(databaseId, {
762
+ limit: 2,
763
+ offset: 0,
764
+ documentIds: [result.documentId],
765
+ });
766
+ const readbackItem = readback.items[0];
767
+ const readbackPropertiesById = new Map(
768
+ readbackItem?.properties.map((property) => [
769
+ property.definition.id,
770
+ property,
771
+ ]) ?? [],
772
+ );
773
+ const readbackValuesMatch = [...values.entries()].every(
774
+ ([propertyId, expectedValueJson]) => {
775
+ const definition = definitionsById.get(propertyId);
776
+ const property = readbackPropertiesById.get(propertyId);
777
+ return (
778
+ definition !== undefined &&
779
+ property !== undefined &&
780
+ normalizedValueJson(
781
+ definition.type as DocumentPropertyType,
782
+ property.value,
783
+ ) === expectedValueJson
784
+ );
785
+ },
786
+ );
787
+ const verifiedDocument = await getDocument.run({
788
+ id: result.documentId,
789
+ databaseId,
790
+ databaseDocumentId: database.documentId,
791
+ });
792
+ if (
793
+ readback.items.length !== 1 ||
794
+ readbackItem?.id !== result.itemId ||
795
+ verifiedDocument?.id !== result.documentId ||
796
+ !readbackValuesMatch ||
797
+ (title !== undefined && readbackItem?.document.title !== title.trim()) ||
798
+ (title !== undefined && verifiedDocument.title !== title.trim()) ||
799
+ (body !== undefined && verifiedDocument.content !== body)
800
+ )
801
+ throw new Error(
802
+ "Stable key upsert could not verify its exact requested row readback.",
803
+ );
804
+ return {
805
+ ...result,
806
+ databaseId,
807
+ keyPropertyId,
808
+ keyValue,
809
+ readback: { items: readback.items, pagination: readback.pagination },
810
+ };
811
+ },
812
+ );
813
+ },
814
+ });