@agent-native/core 0.154.4 → 0.154.5

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 (34) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/.agents/skills/content/SKILL.md +2 -2
  3. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +20 -4
  4. package/corpus/templates/content/actions/_database-row-mutation.ts +1490 -0
  5. package/corpus/templates/content/actions/_database-utils.ts +44 -0
  6. package/corpus/templates/content/actions/_property-utils.ts +4 -1
  7. package/corpus/templates/content/actions/add-database-item.ts +62 -240
  8. package/corpus/templates/content/actions/configure-document-property.ts +165 -1
  9. package/corpus/templates/content/actions/delete-document-property.ts +18 -0
  10. package/corpus/templates/content/actions/set-document-property.ts +78 -1
  11. package/corpus/templates/content/actions/update-database-item.ts +62 -0
  12. package/corpus/templates/content/actions/upsert-database-item-by-key.ts +43 -796
  13. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +10 -8
  14. package/corpus/templates/content/app/hooks/use-content-database.ts +33 -32
  15. package/corpus/templates/content/changelog/2026-08-10-database-row-actions-now-validate-exact-schemas-and-safe-ret.md +6 -0
  16. package/corpus/templates/content/parity/matrix.md +1 -1
  17. package/corpus/templates/content/parity/matrix.ts +2 -0
  18. package/corpus/templates/content/server/db/schema.ts +39 -3
  19. package/corpus/templates/content/server/plugins/db.ts +30 -0
  20. package/corpus/templates/content/shared/api.ts +75 -2
  21. package/dist/action.d.ts +22 -0
  22. package/dist/action.js +27 -0
  23. package/dist/collab/awareness.d.ts +2 -2
  24. package/dist/collab/routes.d.ts +1 -1
  25. package/dist/index.browser.d.ts +1 -1
  26. package/dist/index.browser.js +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/notifications/routes.d.ts +1 -1
  30. package/dist/observability/routes.d.ts +6 -6
  31. package/dist/secrets/routes.d.ts +3 -3
  32. package/dist/server/action-routes.js +10 -2
  33. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  34. package/package.json +1 -1
@@ -45,6 +45,10 @@ import {
45
45
  normalizeContentSpaceEmail,
46
46
  resolveContentSpaceAccess,
47
47
  } from "./_content-space-access.js";
48
+ import {
49
+ databaseRowRevision,
50
+ getDatabaseMutationContract,
51
+ } from "./_database-row-mutation.js";
48
52
  import { getAllContentDatabaseSourceSnapshots } from "./_database-source-utils.js";
49
53
  import {
50
54
  applyFederatedOverlayValues,
@@ -1050,6 +1054,21 @@ export async function getContentDatabasePageResponse(
1050
1054
  queued: bodyHydrationQueued,
1051
1055
  }),
1052
1056
  properties: propertiesByDocumentId.get(document.id) ?? [],
1057
+ rowRevision: databaseRowRevision({
1058
+ itemId: item.id,
1059
+ documentId: document.id,
1060
+ title: document.title,
1061
+ values: (propertiesByDocumentId.get(document.id) ?? [])
1062
+ .filter(
1063
+ (property) =>
1064
+ !isBlocksPropertyType(property.definition.type) &&
1065
+ !isComputedPropertyType(property.definition.type),
1066
+ )
1067
+ .map((property) => ({
1068
+ propertyId: property.definition.id,
1069
+ value: property.value,
1070
+ })),
1071
+ }),
1053
1072
  });
1054
1073
  }
1055
1074
 
@@ -1195,6 +1214,26 @@ export async function getContentDatabaseResponse(
1195
1214
  sources: page.sources,
1196
1215
  pagination: page.pagination,
1197
1216
  tableQueryMode: page.tableQueryMode,
1217
+ mutationContract:
1218
+ page.databaseRecord.spaceId && !page.databaseRecord.systemRole
1219
+ ? await getDatabaseMutationContract(
1220
+ {
1221
+ authorityScope: page.databaseRecord.orgId
1222
+ ? {
1223
+ kind: "organization",
1224
+ id: page.databaseRecord.orgId,
1225
+ }
1226
+ : {
1227
+ kind: "personal",
1228
+ id: page.databaseRecord.ownerEmail,
1229
+ },
1230
+ spaceId: page.databaseRecord.spaceId,
1231
+ databaseId: page.databaseRecord.id,
1232
+ databaseDocumentId: page.databaseRecord.documentId,
1233
+ },
1234
+ { accessAlreadyResolved: true },
1235
+ )
1236
+ : undefined,
1198
1237
  };
1199
1238
  }
1200
1239
 
@@ -1470,6 +1509,11 @@ export async function deleteDatabaseDataForDocument(
1470
1509
  .where(
1471
1510
  eq(schema.contentDatabaseMigrationReceipts.databaseId, database.id),
1472
1511
  );
1512
+ await db
1513
+ .delete(schema.contentDatabaseRowMutationReceipts)
1514
+ .where(
1515
+ eq(schema.contentDatabaseRowMutationReceipts.databaseId, database.id),
1516
+ );
1473
1517
  await db
1474
1518
  .delete(schema.documentPropertyDefinitions)
1475
1519
  .where(eq(schema.documentPropertyDefinitions.databaseId, database.id));
@@ -65,7 +65,8 @@ type ContentDatabaseSummaryRow = Pick<
65
65
  | "viewConfigJson"
66
66
  | "createdAt"
67
67
  | "updatedAt"
68
- >;
68
+ > &
69
+ Partial<Pick<ContentDatabaseRow, "spaceId" | "naturalKeyPropertyId">>;
69
70
  type ContentDatabaseItemRow = InferSelectModel<
70
71
  typeof schema.contentDatabaseItems
71
72
  >;
@@ -199,8 +200,10 @@ export function serializeDatabase(
199
200
  return {
200
201
  id: database.id,
201
202
  documentId: database.documentId,
203
+ spaceId: database.spaceId,
202
204
  title: database.title,
203
205
  systemRole: database.systemRole,
206
+ naturalKeyPropertyId: database.naturalKeyPropertyId,
204
207
  description,
205
208
  viewConfig: parseDatabaseViewConfig(database.viewConfigJson),
206
209
  createdAt: database.createdAt,
@@ -1,255 +1,77 @@
1
1
  import { defineAction } from "@agent-native/core";
2
- import { writeAppState } from "@agent-native/core/application-state";
3
- import { getRequestUserEmail } from "@agent-native/core/server/request-context";
4
- import { assertAccess } from "@agent-native/core/sharing";
5
- import { and, eq, inArray, isNull, sql } from "drizzle-orm";
2
+ import { buildDeepLink } from "@agent-native/core/server";
6
3
  import { z } from "zod";
7
4
 
8
- import { getDb, schema } from "../server/db/index.js";
5
+ import type { ContentDatabaseRowMutationResult } from "../shared/api.js";
9
6
  import {
10
- isComputedPropertyType,
11
- type DocumentPropertyType,
12
- } from "../shared/properties.js";
13
- import {
14
- lockContentDatabaseMutation,
15
- touchContentDatabase,
16
- } from "./_content-database-mutation-lock.js";
17
- import { ensureDocumentFilesMembership } from "./_content-files.js";
7
+ createDatabaseRow,
8
+ databaseMutationEnvelopeSchema,
9
+ } from "./_database-row-mutation.js";
18
10
  import { getContentDatabaseResponse } from "./_database-utils.js";
19
- import {
20
- databaseItemsPositionScope,
21
- documentsPositionScope,
22
- withPositionLock,
23
- } from "./_position-utils.js";
24
- import { nanoid, normalizedValueJson } from "./_property-utils.js";
25
11
 
26
- export default defineAction({
27
- description: "Add a page item to a content database table.",
28
- schema: z.object({
29
- databaseId: z.string().describe("Database ID"),
30
- title: z.string().optional().describe("New row page title"),
31
- propertyValues: z
32
- .record(z.string(), z.unknown())
33
- .optional()
34
- .describe("Initial property values keyed by property definition ID"),
35
- }),
36
- run: async ({ databaseId, title, propertyValues }) => {
37
- const db = getDb();
38
- const [database] = await db
39
- .select()
40
- .from(schema.contentDatabases)
41
- .where(
42
- and(
43
- eq(schema.contentDatabases.id, databaseId),
44
- isNull(schema.contentDatabases.deletedAt),
45
- ),
46
- );
47
- if (!database) throw new Error(`Database "${databaseId}" not found`);
48
- if (database.systemRole === "workspaces") {
49
- throw new Error("Use create-content-space to add a workspace");
50
- }
12
+ const schema = databaseMutationEnvelopeSchema.extend({
13
+ title: z
14
+ .string()
15
+ .trim()
16
+ .min(1)
17
+ .max(500)
18
+ .optional()
19
+ .describe("New row page title"),
20
+ propertyValues: z
21
+ .record(z.string(), z.unknown())
22
+ .optional()
23
+ .describe("Strict property values keyed by property definition ID"),
24
+ });
51
25
 
52
- const access = await assertAccess(
53
- "document",
54
- database.documentId,
55
- "editor",
26
+ export default defineAction({
27
+ description:
28
+ "Create one row in an exact ordinary Content database using its discovered schema revision. Strictly validates every non-Blocks property, applies the side effect once per idempotency key, and returns a verified receipt with stable row identity.",
29
+ schema,
30
+ audit: {
31
+ recordInputs: false,
32
+ target: (args) => ({
33
+ type: "content-database",
34
+ id: args.target.databaseId,
35
+ visibility: "private",
36
+ }),
37
+ summary: (_args, result) => {
38
+ const receipt = (result as ContentDatabaseRowMutationResult | null)
39
+ ?.receipt;
40
+ return receipt
41
+ ? `Created Content database row ${receipt.row.itemId}`
42
+ : "Created Content database row";
43
+ },
44
+ },
45
+ run: async (args): Promise<ContentDatabaseRowMutationResult> => {
46
+ const result = await createDatabaseRow(args);
47
+ const response = await getContentDatabaseResponse(
48
+ result.receipt.target.databaseId,
49
+ {
50
+ limit: 1,
51
+ offset: 0,
52
+ documentIds: [result.receipt.row.documentId],
53
+ },
56
54
  );
57
- const databaseDocument = access.resource;
58
- if (
59
- database.spaceId &&
60
- databaseDocument.spaceId &&
61
- databaseDocument.spaceId !== database.spaceId
62
- ) {
55
+ const createdItem = response.items[0];
56
+ if (!createdItem || createdItem.id !== result.receipt.row.itemId) {
63
57
  throw new Error(
64
- `Database "${databaseId}" has inconsistent Content space`,
58
+ "Created row receipt did not resolve to its exact read-back.",
65
59
  );
66
60
  }
67
- const now = new Date().toISOString();
68
- const databaseSpaceId =
69
- database.spaceId ?? (databaseDocument.spaceId as string | null);
70
- if (!databaseSpaceId) {
71
- throw new Error("Database does not belong to a Content space.");
72
- }
73
- if (databaseSpaceId && (!database.spaceId || !databaseDocument.spaceId)) {
74
- await db.transaction(async (tx) => {
75
- if (!database.spaceId) {
76
- await tx
77
- .update(schema.contentDatabases)
78
- .set({ spaceId: databaseSpaceId, updatedAt: now })
79
- .where(eq(schema.contentDatabases.id, databaseId));
80
- }
81
- if (!databaseDocument.spaceId) {
82
- await tx
83
- .update(schema.documents)
84
- .set({ spaceId: databaseSpaceId, updatedAt: now })
85
- .where(eq(schema.documents.id, database.documentId));
86
- }
87
- await ensureDocumentFilesMembership(tx, database.documentId, now);
88
- });
89
- }
90
-
91
- const documentId = nanoid();
92
- const itemId = nanoid();
93
-
94
- const inheritedShares = await db
95
- .select({
96
- principalType: schema.documentShares.principalType,
97
- principalId: schema.documentShares.principalId,
98
- role: schema.documentShares.role,
99
- })
100
- .from(schema.documentShares)
101
- .where(eq(schema.documentShares.resourceId, database.documentId));
102
-
103
- const initialValues = Object.entries(propertyValues ?? {});
104
-
105
- await withPositionLock(
106
- documentsPositionScope(database.ownerEmail, database.documentId),
107
- () =>
108
- withPositionLock(databaseItemsPositionScope(databaseId), async () => {
109
- await db.transaction(async (tx) => {
110
- await lockContentDatabaseMutation(
111
- tx as unknown as ReturnType<typeof getDb>,
112
- databaseId,
113
- );
114
- await touchContentDatabase(
115
- tx as unknown as ReturnType<typeof getDb>,
116
- databaseId,
117
- now,
118
- );
119
- const propertyValueRows: Array<
120
- typeof schema.documentPropertyValues.$inferInsert
121
- > = [];
122
- if (initialValues.length > 0) {
123
- const requestedPropertyIds = initialValues.map(
124
- ([propertyId]) => propertyId,
125
- );
126
- const definitions = await tx
127
- .select()
128
- .from(schema.documentPropertyDefinitions)
129
- .where(
130
- and(
131
- eq(
132
- schema.documentPropertyDefinitions.ownerEmail,
133
- database.ownerEmail,
134
- ),
135
- eq(
136
- schema.documentPropertyDefinitions.databaseId,
137
- databaseId,
138
- ),
139
- inArray(
140
- schema.documentPropertyDefinitions.id,
141
- requestedPropertyIds,
142
- ),
143
- ),
144
- );
145
- const definitionById = new Map(
146
- definitions.map((definition) => [definition.id, definition]),
147
- );
148
- for (const [propertyId, value] of initialValues) {
149
- const definition = definitionById.get(propertyId);
150
- const type = definition?.type as
151
- | DocumentPropertyType
152
- | undefined;
153
- if (!definition || !type || isComputedPropertyType(type))
154
- continue;
155
- propertyValueRows.push({
156
- id: nanoid(),
157
- ownerEmail: database.ownerEmail,
158
- documentId,
159
- propertyId,
160
- valueJson: normalizedValueJson(type, value),
161
- createdAt: now,
162
- updatedAt: now,
163
- });
164
- }
165
- }
166
- const [maxDocPos] = await tx
167
- .select({ max: sql<number>`COALESCE(MAX(position), -1)` })
168
- .from(schema.documents)
169
- .where(
170
- and(
171
- eq(schema.documents.ownerEmail, database.ownerEmail),
172
- eq(schema.documents.parentId, database.documentId),
173
- ),
174
- );
175
- const [maxItemPos] = await tx
176
- .select({ max: sql<number>`COALESCE(MAX(position), -1)` })
177
- .from(schema.contentDatabaseItems)
178
- .where(eq(schema.contentDatabaseItems.databaseId, databaseId));
179
-
180
- await tx.insert(schema.documents).values({
181
- id: documentId,
182
- spaceId: databaseSpaceId,
183
- ownerEmail: database.ownerEmail,
184
- orgId: database.orgId,
185
- parentId: database.documentId,
186
- title: title?.trim() ?? "",
187
- content: "",
188
- icon: null,
189
- position: (maxDocPos?.max ?? -1) + 1,
190
- isFavorite: 0,
191
- hideFromSearch: databaseDocument.hideFromSearch ?? 0,
192
- visibility: databaseDocument.visibility ?? "private",
193
- createdAt: now,
194
- updatedAt: now,
195
- });
196
- await tx.insert(schema.contentDatabaseItems).values({
197
- id: itemId,
198
- ownerEmail: database.ownerEmail,
199
- orgId: database.orgId,
200
- databaseId,
201
- documentId,
202
- position: (maxItemPos?.max ?? -1) + 1,
203
- createdAt: now,
204
- updatedAt: now,
205
- });
206
- if (inheritedShares.length > 0) {
207
- await tx.insert(schema.documentShares).values(
208
- inheritedShares.map((share) => ({
209
- id: nanoid(),
210
- resourceId: documentId,
211
- principalType: share.principalType,
212
- principalId: share.principalId,
213
- role: share.role,
214
- createdBy: getRequestUserEmail() ?? database.ownerEmail,
215
- createdAt: now,
216
- })),
217
- );
218
- }
219
- if (propertyValueRows.length > 0) {
220
- await tx
221
- .insert(schema.documentPropertyValues)
222
- .values(propertyValueRows);
223
- }
224
- await ensureDocumentFilesMembership(tx, documentId, now);
225
- });
226
- }),
227
- );
228
-
229
- await writeAppState("refresh-signal", { ts: Date.now() }).catch(() => {
230
- // The row is already committed; polling will reconcile if a concurrent
231
- // SQLite writer briefly blocks this best-effort refresh hint.
232
- });
233
-
234
- const response = await getContentDatabaseResponse(databaseId, {
235
- limit: 100,
236
- offset: 0,
237
- });
238
- const createdItem =
239
- response.items.find((item) => item.id === itemId) ??
240
- (
241
- await getContentDatabaseResponse(databaseId, {
242
- limit: 1,
243
- offset: 0,
244
- documentIds: [documentId],
245
- })
246
- ).items.find((item) => item.id === itemId);
61
+ return { ...result, createdItem };
62
+ },
63
+ link: ({ result }) => {
64
+ const documentId = (result as ContentDatabaseRowMutationResult | null)
65
+ ?.receipt.row.documentId;
66
+ if (!documentId) return null;
247
67
  return {
248
- ...response,
249
- createdItem,
250
- createdItemId: itemId,
251
- createdDocumentId: documentId,
252
- createdDocumentUpdatedAt: now,
68
+ url: buildDeepLink({
69
+ app: "content",
70
+ view: "editor",
71
+ params: { documentId },
72
+ }),
73
+ label: "Open database row",
74
+ view: "editor",
253
75
  };
254
76
  },
255
77
  });
@@ -52,6 +52,12 @@ export default defineAction({
52
52
  "Stable guidance describing what this property means and which value belongs here",
53
53
  ),
54
54
  type: z.enum(CREATABLE_DOCUMENT_PROPERTY_TYPES).describe("Property type"),
55
+ naturalKey: z
56
+ .boolean()
57
+ .optional()
58
+ .describe(
59
+ "Declare or clear this ordinary text property as the database's single natural key",
60
+ ),
55
61
  visibility: z
56
62
  .enum(DOCUMENT_PROPERTY_VISIBILITIES)
57
63
  .optional()
@@ -104,6 +110,7 @@ export default defineAction({
104
110
  const now = new Date().toISOString();
105
111
  const name = args.name.trim();
106
112
  const type = args.type as DocumentPropertyType;
113
+ const propertyId = args.id ?? nanoid();
107
114
  const optionsJson = optionsForNewProperty(type, args.options as any);
108
115
  const database = await resolvePropertyDatabaseForDocument(
109
116
  document,
@@ -115,6 +122,14 @@ export default defineAction({
115
122
  "Properties belong to databases. Create or open a database before adding properties.",
116
123
  );
117
124
  }
125
+ if (args.naturalKey === true && type !== "text") {
126
+ throw new Error(
127
+ "A database natural key must be an ordinary text property.",
128
+ );
129
+ }
130
+ if (args.naturalKey !== undefined && database.systemRole) {
131
+ throw new Error("System databases cannot configure a natural key.");
132
+ }
118
133
 
119
134
  if (args.id) {
120
135
  const [existing] = await db
@@ -136,6 +151,11 @@ export default defineAction({
136
151
  tx as unknown as ReturnType<typeof getDb>,
137
152
  database.id,
138
153
  );
154
+ const [lockedDatabase] = await tx
155
+ .select()
156
+ .from(schema.contentDatabases)
157
+ .where(eq(schema.contentDatabases.id, database.id));
158
+ if (!lockedDatabase) throw new Error("Database not found.");
139
159
  let [lockedDefinition] = await tx
140
160
  .select()
141
161
  .from(schema.documentPropertyDefinitions)
@@ -151,6 +171,14 @@ export default defineAction({
151
171
  );
152
172
  if (!lockedDefinition)
153
173
  throw new Error(`Property "${args.id}" not found`);
174
+ if (
175
+ lockedDatabase.naturalKeyPropertyId === args.id &&
176
+ type !== "text"
177
+ ) {
178
+ throw new Error(
179
+ "Clear the database natural key before changing this property's type.",
180
+ );
181
+ }
154
182
  if (lockedDefinition.systemRole) {
155
183
  throw new Error("System properties cannot be changed.");
156
184
  }
@@ -265,6 +293,13 @@ export default defineAction({
265
293
  updatedAt: now,
266
294
  })
267
295
  .where(eq(schema.documentPropertyDefinitions.id, args.id!));
296
+ await configureNaturalKey(tx, {
297
+ database: lockedDatabase,
298
+ propertyId,
299
+ naturalKey: args.naturalKey,
300
+ ownerEmail: document.ownerEmail,
301
+ now,
302
+ });
268
303
  });
269
304
  } else {
270
305
  await withPositionLock(
@@ -275,6 +310,11 @@ export default defineAction({
275
310
  tx as unknown as ReturnType<typeof getDb>,
276
311
  database.id,
277
312
  );
313
+ const [lockedDatabase] = await tx
314
+ .select()
315
+ .from(schema.contentDatabases)
316
+ .where(eq(schema.contentDatabases.id, database.id));
317
+ if (!lockedDatabase) throw new Error("Database not found.");
278
318
  const [maxPos] = await tx
279
319
  .select({
280
320
  max: sql<number>`COALESCE(MAX(position), -1)`,
@@ -294,7 +334,7 @@ export default defineAction({
294
334
  );
295
335
 
296
336
  await tx.insert(schema.documentPropertyDefinitions).values({
297
- id: nanoid(),
337
+ id: propertyId,
298
338
  ownerEmail: document.ownerEmail,
299
339
  orgId: document.orgId ?? null,
300
340
  databaseId: database.id,
@@ -307,6 +347,13 @@ export default defineAction({
307
347
  createdAt: now,
308
348
  updatedAt: now,
309
349
  });
350
+ await configureNaturalKey(tx, {
351
+ database: lockedDatabase,
352
+ propertyId,
353
+ naturalKey: args.naturalKey,
354
+ ownerEmail: document.ownerEmail,
355
+ now,
356
+ });
310
357
  });
311
358
  },
312
359
  );
@@ -321,3 +368,120 @@ export default defineAction({
321
368
  };
322
369
  },
323
370
  });
371
+
372
+ async function configureNaturalKey(
373
+ tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0],
374
+ args: {
375
+ database: typeof schema.contentDatabases.$inferSelect;
376
+ propertyId: string;
377
+ naturalKey: boolean | undefined;
378
+ ownerEmail: string;
379
+ now: string;
380
+ },
381
+ ) {
382
+ if (args.naturalKey === undefined) return;
383
+ if (!args.naturalKey) {
384
+ if (args.database.naturalKeyPropertyId === args.propertyId) {
385
+ await tx
386
+ .delete(schema.contentDatabaseItemKeyClaims)
387
+ .where(
388
+ and(
389
+ eq(
390
+ schema.contentDatabaseItemKeyClaims.databaseId,
391
+ args.database.id,
392
+ ),
393
+ eq(schema.contentDatabaseItemKeyClaims.propertyId, args.propertyId),
394
+ ),
395
+ );
396
+ await tx
397
+ .update(schema.contentDatabases)
398
+ .set({ naturalKeyPropertyId: null, updatedAt: args.now })
399
+ .where(eq(schema.contentDatabases.id, args.database.id));
400
+ }
401
+ return;
402
+ }
403
+ if (
404
+ args.database.naturalKeyPropertyId &&
405
+ args.database.naturalKeyPropertyId !== args.propertyId
406
+ ) {
407
+ throw new Error(
408
+ "Clear the existing database natural key before configuring another one.",
409
+ );
410
+ }
411
+ const [sourceField] = await tx
412
+ .select({ id: schema.contentDatabaseSourceFields.id })
413
+ .from(schema.contentDatabaseSourceFields)
414
+ .where(eq(schema.contentDatabaseSourceFields.propertyId, args.propertyId))
415
+ .limit(1);
416
+ if (sourceField) {
417
+ throw new Error("A source-managed property cannot be a natural key.");
418
+ }
419
+ const values = await tx
420
+ .select({
421
+ valueJson: schema.documentPropertyValues.valueJson,
422
+ itemId: schema.contentDatabaseItems.id,
423
+ documentId: schema.contentDatabaseItems.documentId,
424
+ })
425
+ .from(schema.contentDatabaseItems)
426
+ .innerJoin(
427
+ schema.documentPropertyValues,
428
+ and(
429
+ eq(
430
+ schema.documentPropertyValues.documentId,
431
+ schema.contentDatabaseItems.documentId,
432
+ ),
433
+ eq(schema.documentPropertyValues.propertyId, args.propertyId),
434
+ ),
435
+ )
436
+ .where(eq(schema.contentDatabaseItems.databaseId, args.database.id));
437
+ const claims = new Map<string, (typeof values)[number]>();
438
+ for (const value of values) {
439
+ let parsed: unknown;
440
+ try {
441
+ parsed = JSON.parse(value.valueJson);
442
+ } catch {
443
+ throw new Error("Natural key values must be readable strings.");
444
+ }
445
+ if (parsed === null || parsed === "") continue;
446
+ if (typeof parsed !== "string") {
447
+ throw new Error("Natural key values must be non-empty strings.");
448
+ }
449
+ if (!parsed.trim()) {
450
+ throw new Error("Natural key values must be non-empty strings.");
451
+ }
452
+ if (claims.has(value.valueJson)) {
453
+ throw new Error(
454
+ `Natural key value ${value.valueJson} belongs to more than one row.`,
455
+ );
456
+ }
457
+ claims.set(value.valueJson, value);
458
+ }
459
+ await tx
460
+ .delete(schema.contentDatabaseItemKeyClaims)
461
+ .where(
462
+ and(
463
+ eq(schema.contentDatabaseItemKeyClaims.databaseId, args.database.id),
464
+ eq(schema.contentDatabaseItemKeyClaims.propertyId, args.propertyId),
465
+ ),
466
+ );
467
+ if (claims.size > 0) {
468
+ await tx.insert(schema.contentDatabaseItemKeyClaims).values(
469
+ [...claims.entries()].map(([keyValueJson, value]) => ({
470
+ id: nanoid(),
471
+ ownerEmail: args.ownerEmail,
472
+ orgId: args.database.orgId,
473
+ databaseId: args.database.id,
474
+ propertyId: args.propertyId,
475
+ keyValueJson,
476
+ itemId: value.itemId,
477
+ documentId: value.documentId,
478
+ createdAt: args.now,
479
+ updatedAt: args.now,
480
+ })),
481
+ );
482
+ }
483
+ await tx
484
+ .update(schema.contentDatabases)
485
+ .set({ naturalKeyPropertyId: args.propertyId, updatedAt: args.now })
486
+ .where(eq(schema.contentDatabases.id, args.database.id));
487
+ }
@@ -69,6 +69,14 @@ export default defineAction({
69
69
  tx as unknown as ReturnType<typeof getDb>,
70
70
  database.id,
71
71
  );
72
+ const [lockedDatabase] = await tx
73
+ .select({
74
+ naturalKeyPropertyId: schema.contentDatabases.naturalKeyPropertyId,
75
+ })
76
+ .from(schema.contentDatabases)
77
+ .where(eq(schema.contentDatabases.id, database.id));
78
+ if (!lockedDatabase)
79
+ throw new Error(`Database "${database.id}" not found`);
72
80
  const [lockedDefinition] = await tx
73
81
  .select()
74
82
  .from(schema.documentPropertyDefinitions)
@@ -131,6 +139,16 @@ export default defineAction({
131
139
  .delete(schema.documentPropertyDefinitions)
132
140
  .where(eq(schema.documentPropertyDefinitions.id, propertyId));
133
141
 
142
+ if (lockedDatabase.naturalKeyPropertyId === propertyId) {
143
+ await tx
144
+ .update(schema.contentDatabases)
145
+ .set({
146
+ naturalKeyPropertyId: null,
147
+ updatedAt: new Date().toISOString(),
148
+ })
149
+ .where(eq(schema.contentDatabases.id, database.id));
150
+ }
151
+
134
152
  if (isBlocks) {
135
153
  await deleteBlocksFieldIdentity({
136
154
  db: tx as unknown as ReturnType<typeof getDb>,