@agent-native/core 0.160.1 → 0.161.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 (92) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/actions/bigquery.ts +57 -0
  3. package/corpus/templates/analytics/actions/restore-analysis-revision.ts +5 -2
  4. package/corpus/templates/analytics/actions/restore-dashboard-revision.ts +5 -2
  5. package/corpus/templates/analytics/app/components/analysis/AnalysisHistoryPanel.tsx +14 -3
  6. package/corpus/templates/analytics/app/components/dashboard/DashboardHistoryPanel.tsx +14 -3
  7. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +3 -0
  8. package/corpus/templates/analytics/app/hooks/use-analysis-revisions.ts +1 -0
  9. package/corpus/templates/analytics/app/hooks/use-dashboard-revisions.ts +16 -1
  10. package/corpus/templates/analytics/app/lib/use-auto-focus-select.ts +19 -0
  11. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +0 -1
  12. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-sync.ts +14 -0
  13. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +19 -0
  14. package/corpus/templates/analytics/changelog/2026-08-17-fixed-dashboard-editing-focus-history-recovery-panel-setup-f.md +6 -0
  15. package/corpus/templates/calendar/actions/create-event.ts +7 -3
  16. package/corpus/templates/calendar/actions/manage-event-draft.ts +8 -0
  17. package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +64 -0
  18. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +5 -1
  19. package/corpus/templates/calendar/app/hooks/use-events.ts +1 -0
  20. package/corpus/templates/calendar/changelog/2026-08-17-booking-links-now-respect-each-required-host-s-saved-availab.md +6 -0
  21. package/corpus/templates/calendar/server/lib/booking-host-availability.ts +76 -0
  22. package/corpus/templates/calendar/server/lib/google-calendar.ts +2 -0
  23. package/corpus/templates/calendar/shared/api.ts +1 -1
  24. package/corpus/templates/clips/app/components/player/signed-out-share-actions.tsx +47 -0
  25. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +3 -1
  26. package/corpus/templates/clips/app/routes/share.$shareId.tsx +27 -19
  27. package/corpus/templates/clips/changelog/2026-08-17-shared-clips-sign-in-comment-no-audio.md +6 -0
  28. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +6 -5
  29. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +13 -0
  30. package/corpus/templates/content/AGENTS.md +2 -0
  31. package/corpus/templates/content/actions/_blocks-field-identity.ts +17 -0
  32. package/corpus/templates/content/actions/_database-block-actions.ts +958 -0
  33. package/corpus/templates/content/actions/_database-row-mutation.ts +7 -7
  34. package/corpus/templates/content/actions/list-content-database-blocks.ts +31 -0
  35. package/corpus/templates/content/actions/mutate-content-database-block.ts +49 -0
  36. package/corpus/templates/content/changelog/2026-08-10-agents-can-safely-insert-update-upsert-delete-and-reorder-on.md +6 -0
  37. package/corpus/templates/content/docs/product/capabilities/content.object.block.md +6 -2
  38. package/corpus/templates/content/docs/product/capabilities/content.object.blocks-field.md +2 -1
  39. package/corpus/templates/content/parity/matrix.md +30 -30
  40. package/corpus/templates/content/parity/matrix.ts +4 -1
  41. package/corpus/templates/content/shared/blocks-field-identity.ts +114 -37
  42. package/corpus/templates/content/shared/database-block-actions.ts +82 -0
  43. package/corpus/templates/content/shared/database-block-mutations.ts +366 -0
  44. package/corpus/templates/content/shared/nfm.ts +3 -2
  45. package/corpus/templates/mail/.agents/skills/email-drafts/SKILL.md +24 -0
  46. package/corpus/templates/mail/actions/get-mail-settings.ts +1 -1
  47. package/corpus/templates/mail/actions/update-mail-settings.ts +7 -3
  48. package/corpus/templates/mail/app/i18n/en-US.ts +3 -1
  49. package/corpus/templates/mail/app/pages/SettingsPage.tsx +63 -0
  50. package/corpus/templates/mail/changelog/2026-08-17-gmail-signatures-now-preserve-images-and-support-paste-or-up.md +6 -0
  51. package/corpus/templates/mail/changelog/2026-08-17-mail-now-updates-durable-drafting-preferences-without-openin.md +6 -0
  52. package/corpus/templates/mail/server/plugins/agent-chat.ts +19 -0
  53. package/corpus/templates/mail/shared/gmail-signature.ts +14 -2
  54. package/corpus/templates/mail/shared/signature.ts +1 -10
  55. package/corpus/templates/slides/actions/generate-slides-ai.ts +2 -0
  56. package/corpus/templates/slides/actions/patch-deck.ts +1 -1
  57. package/corpus/templates/slides/app/components/ThemeToggle.tsx +1 -0
  58. package/corpus/templates/slides/app/components/editor/SlideThumbnailContextMenu.tsx +59 -0
  59. package/corpus/templates/slides/app/global.css +1 -0
  60. package/corpus/templates/slides/app/lib/normalize-slide-padding.ts +40 -11
  61. package/corpus/templates/slides/changelog/2026-08-17-stable-layout-and-slide-editing.md +6 -0
  62. package/corpus/templates/slides/shared/deck-title.ts +12 -4
  63. package/dist/agent/production-agent.js +17 -4
  64. package/dist/agent/tool-approval-migrations.d.ts +1 -0
  65. package/dist/agent/tool-approval-migrations.js +10 -0
  66. package/dist/agent/tool-approval-store.d.ts +12 -0
  67. package/dist/agent/tool-approval-store.js +46 -1
  68. package/dist/agent/types.d.ts +3 -1
  69. package/dist/client/AssistantChat.js +3 -1
  70. package/dist/localization/core-messages/ar-SA.js +1 -0
  71. package/dist/localization/core-messages/de-DE.js +1 -0
  72. package/dist/localization/core-messages/en-US.d.ts +1 -0
  73. package/dist/localization/core-messages/en-US.js +1 -0
  74. package/dist/localization/core-messages/es-ES.js +1 -0
  75. package/dist/localization/core-messages/fr-FR.js +1 -0
  76. package/dist/localization/core-messages/hi-IN.js +1 -0
  77. package/dist/localization/core-messages/ja-JP.js +1 -0
  78. package/dist/localization/core-messages/ko-KR.js +1 -0
  79. package/dist/localization/core-messages/pt-BR.js +1 -0
  80. package/dist/localization/core-messages/zh-CN.js +1 -0
  81. package/dist/localization/core-messages/zh-TW.js +1 -0
  82. package/dist/localization/core-messages.d.ts +1 -0
  83. package/dist/resources/handlers.d.ts +1 -1
  84. package/dist/scripts/resources/read.js +4 -4
  85. package/dist/scripts/resources/save-memory.js +15 -2
  86. package/dist/scripts/resources/write.js +3 -3
  87. package/dist/server/agent-chat/script-entries.d.ts +1 -0
  88. package/dist/server/agent-chat/script-entries.js +8 -0
  89. package/dist/server/realtime-token.d.ts +1 -1
  90. package/dist/server/transcribe-voice.d.ts +1 -1
  91. package/docs/content/onboarding.mdx +13 -2
  92. package/package.json +3 -3
@@ -0,0 +1,958 @@
1
+ import { ActionContractError } from "@agent-native/core";
2
+ import { assertAccess } from "@agent-native/core/sharing";
3
+ import { and, eq, isNull, sql } from "drizzle-orm";
4
+ import { z } from "zod";
5
+
6
+ import { getDb, schema } from "../server/db/index.js";
7
+ import {
8
+ BLOCKS_FIELD_BLOCK_KINDS,
9
+ BLOCKS_FIELD_OPERATION_CAPABILITIES,
10
+ snapshotBlocksFieldMarkdown,
11
+ type BlocksFieldBlockKind,
12
+ type BlocksFieldIdentity,
13
+ } from "../shared/blocks-field-identity.js";
14
+ import type {
15
+ ContentDatabaseBlock,
16
+ ContentDatabaseBlockMutationReceipt,
17
+ ContentDatabaseBlockMutationResult,
18
+ ContentDatabaseBlocksReadResult,
19
+ } from "../shared/database-block-actions.js";
20
+ import {
21
+ mutateBlocksFieldDocument,
22
+ type BlockDocumentMutation,
23
+ } from "../shared/database-block-mutations.js";
24
+ import {
25
+ blocksStorageTarget,
26
+ isBlocksPropertyType,
27
+ parsePropertyOptions,
28
+ type BlocksStorageTarget,
29
+ type DocumentPropertyType,
30
+ } from "../shared/properties.js";
31
+ import {
32
+ BlocksFieldIdCollisionError,
33
+ lockPrimaryBlocksFields,
34
+ persistBlocksFieldIdentity,
35
+ readBlocksFieldIdentity,
36
+ } from "./_blocks-field-identity.js";
37
+ import {
38
+ lockContentDatabaseMutation,
39
+ touchContentDatabase,
40
+ withContentDatabaseMutationLock,
41
+ } from "./_content-database-mutation-lock.js";
42
+ import {
43
+ assertSchema,
44
+ databaseMutationTargetSchema,
45
+ digest,
46
+ loadContext,
47
+ revisionPropertyIds,
48
+ rowSnapshot,
49
+ type DatabaseMutationTarget,
50
+ type MutationContext,
51
+ type RowSnapshot,
52
+ } from "./_database-row-mutation.js";
53
+ import { nanoid } from "./_property-utils.js";
54
+
55
+ type Db = ReturnType<typeof getDb>;
56
+
57
+ const blockKindSchema = z.enum(BLOCKS_FIELD_BLOCK_KINDS);
58
+ const blockValueSchema = z.object({
59
+ kind: blockKindSchema,
60
+ nfm: z.string().max(1_000_000),
61
+ });
62
+ const placementSchema = z.discriminatedUnion("placement", [
63
+ z.object({
64
+ placement: z.enum(["start", "end"]),
65
+ parentBlockId: z.string().min(1).nullable().optional(),
66
+ }),
67
+ z.object({
68
+ placement: z.enum(["before", "after"]),
69
+ anchorBlockId: z.string().min(1),
70
+ }),
71
+ ]);
72
+
73
+ export const databaseBlockTargetSchema = databaseMutationTargetSchema.extend({
74
+ itemId: z.string().min(1).describe("Exact database membership row ID"),
75
+ rowDocumentId: z.string().min(1).describe("Exact row page ID"),
76
+ propertyId: z.string().min(1).describe("Exact Blocks property ID"),
77
+ });
78
+
79
+ export const listDatabaseBlocksSchema = z.object({
80
+ target: databaseBlockTargetSchema,
81
+ limit: z.coerce.number().int().min(1).max(100).default(50),
82
+ cursor: z.string().min(1).optional(),
83
+ });
84
+
85
+ const mutationEnvelopeSchema = z.object({
86
+ target: databaseBlockTargetSchema,
87
+ expectedSchemaRevision: z.string().min(1),
88
+ expectedRowRevision: z.string().min(1),
89
+ expectedFieldRevision: z.number().int().nonnegative(),
90
+ idempotencyKey: z.string().min(1).max(200),
91
+ });
92
+
93
+ const mutateDatabaseBlockOperationSchema = z.discriminatedUnion("operation", [
94
+ mutationEnvelopeSchema.extend({
95
+ operation: z.literal("insert"),
96
+ block: blockValueSchema,
97
+ position: placementSchema,
98
+ }),
99
+ mutationEnvelopeSchema.extend({
100
+ operation: z.literal("update"),
101
+ blockId: z.string().min(1),
102
+ block: blockValueSchema,
103
+ }),
104
+ mutationEnvelopeSchema.extend({
105
+ operation: z.literal("upsert"),
106
+ blockId: z.string().min(1),
107
+ block: blockValueSchema,
108
+ position: placementSchema.optional(),
109
+ }),
110
+ mutationEnvelopeSchema.extend({
111
+ operation: z.literal("delete"),
112
+ blockId: z.string().min(1),
113
+ }),
114
+ mutationEnvelopeSchema.extend({
115
+ operation: z.literal("reorder"),
116
+ blockId: z.string().min(1),
117
+ position: placementSchema,
118
+ }),
119
+ ]);
120
+
121
+ type MutationInput = z.infer<typeof mutateDatabaseBlockOperationSchema>;
122
+
123
+ // Agent tool registration requires a top-level object schema. Keep the
124
+ // discriminated union as the exact validator for operation-specific fields.
125
+ export const mutateDatabaseBlockSchema = z
126
+ .object({
127
+ ...mutationEnvelopeSchema.shape,
128
+ operation: z.enum(["insert", "update", "upsert", "delete", "reorder"]),
129
+ blockId: z.string().min(1).optional(),
130
+ block: blockValueSchema.optional(),
131
+ position: placementSchema.optional(),
132
+ })
133
+ .superRefine((value, context) => {
134
+ const parsed = mutateDatabaseBlockOperationSchema.safeParse(value);
135
+ if (parsed.success) return;
136
+ for (const issue of parsed.error.issues) {
137
+ context.addIssue({
138
+ code: "custom",
139
+ path: issue.path,
140
+ message: issue.message,
141
+ });
142
+ }
143
+ }) as z.ZodType<MutationInput>;
144
+
145
+ type BlockTarget = z.infer<typeof databaseBlockTargetSchema>;
146
+
147
+ interface LoadedField {
148
+ context: MutationContext;
149
+ row: RowSnapshot;
150
+ markdown: string;
151
+ identity: BlocksFieldIdentity;
152
+ ownerEmail: string;
153
+ storageTarget: BlocksStorageTarget;
154
+ storageRowExists: boolean;
155
+ }
156
+
157
+ function contractError(
158
+ errorCode: string,
159
+ message: string,
160
+ details?: Record<string, unknown>,
161
+ statusCode = 409,
162
+ ): never {
163
+ throw new ActionContractError(message, { errorCode, details, statusCode });
164
+ }
165
+
166
+ function isUniqueConstraintError(error: unknown): boolean {
167
+ const candidate = error as { code?: unknown; message?: unknown };
168
+ const code = String(candidate?.code ?? "");
169
+ const message = String(candidate?.message ?? "");
170
+ return (
171
+ code === "23505" ||
172
+ code.includes("SQLITE_CONSTRAINT") ||
173
+ /unique constraint|primary key constraint|duplicate key/i.test(message)
174
+ );
175
+ }
176
+
177
+ function databaseTarget(target: BlockTarget): DatabaseMutationTarget {
178
+ return {
179
+ authorityScope: target.authorityScope,
180
+ spaceId: target.spaceId,
181
+ databaseId: target.databaseId,
182
+ databaseDocumentId: target.databaseDocumentId,
183
+ };
184
+ }
185
+
186
+ async function loadField(args: {
187
+ target: BlockTarget;
188
+ role: "viewer" | "editor";
189
+ db?: Db;
190
+ accessAlreadyResolved?: boolean;
191
+ }): Promise<LoadedField> {
192
+ const db = args.db ?? getDb();
193
+ const context = await loadContext(
194
+ databaseTarget(args.target),
195
+ args.role,
196
+ db,
197
+ args.accessAlreadyResolved,
198
+ );
199
+ if (!args.accessAlreadyResolved) {
200
+ await assertAccess("document", args.target.rowDocumentId, args.role);
201
+ }
202
+ const row = await rowSnapshot(
203
+ db,
204
+ args.target.databaseId,
205
+ args.target.itemId,
206
+ args.target.rowDocumentId,
207
+ revisionPropertyIds(context),
208
+ );
209
+ if (!row) {
210
+ contractError(
211
+ "ROW_NOT_FOUND",
212
+ "Content database row not found.",
213
+ {
214
+ itemId: args.target.itemId,
215
+ rowDocumentId: args.target.rowDocumentId,
216
+ },
217
+ 404,
218
+ );
219
+ }
220
+ const definition = context.definitions.find(
221
+ (candidate) => candidate.id === args.target.propertyId,
222
+ );
223
+ if (
224
+ !definition ||
225
+ !isBlocksPropertyType(definition.type as DocumentPropertyType)
226
+ ) {
227
+ contractError(
228
+ "BLOCKS_PROPERTY_NOT_FOUND",
229
+ "The exact property is not a Blocks field in this database schema.",
230
+ { propertyId: args.target.propertyId },
231
+ 400,
232
+ );
233
+ }
234
+ if (definition.systemRole) {
235
+ contractError(
236
+ "BLOCKS_PROPERTY_UNSUPPORTED",
237
+ "System Blocks properties cannot be mutated individually.",
238
+ { propertyId: definition.id },
239
+ 400,
240
+ );
241
+ }
242
+ if (
243
+ args.role === "editor" &&
244
+ context.sourceManagedPropertyIds.has(definition.id)
245
+ ) {
246
+ contractError(
247
+ "SOURCE_MANAGED_PROPERTY",
248
+ "Source-managed Blocks properties cannot be mutated locally.",
249
+ { propertyId: definition.id },
250
+ 400,
251
+ );
252
+ }
253
+ const storageTarget = blocksStorageTarget(
254
+ parsePropertyOptions(definition.optionsJson),
255
+ );
256
+ let markdown: string;
257
+ let storageRowExists = true;
258
+ if (storageTarget === "document_body") {
259
+ markdown = row.document.content;
260
+ } else {
261
+ const [field] = await db
262
+ .select({ content: schema.documentBlockFieldContents.content })
263
+ .from(schema.documentBlockFieldContents)
264
+ .where(
265
+ and(
266
+ eq(
267
+ schema.documentBlockFieldContents.documentId,
268
+ args.target.rowDocumentId,
269
+ ),
270
+ eq(
271
+ schema.documentBlockFieldContents.propertyId,
272
+ args.target.propertyId,
273
+ ),
274
+ ),
275
+ );
276
+ storageRowExists = field !== undefined;
277
+ markdown = field?.content ?? "";
278
+ }
279
+ const identity = await readBlocksFieldIdentity({
280
+ db,
281
+ documentId: args.target.rowDocumentId,
282
+ propertyId: args.target.propertyId,
283
+ markdown,
284
+ });
285
+ return {
286
+ context,
287
+ row,
288
+ markdown,
289
+ identity,
290
+ ownerEmail: context.database.ownerEmail,
291
+ storageTarget,
292
+ storageRowExists,
293
+ };
294
+ }
295
+
296
+ function serializedBlocks(
297
+ markdown: string,
298
+ identity: BlocksFieldIdentity,
299
+ ): ContentDatabaseBlock[] {
300
+ const snapshots = snapshotBlocksFieldMarkdown(markdown);
301
+ if (snapshots.length !== identity.blocks.length) {
302
+ contractError(
303
+ "BLOCK_IDENTITY_STALE",
304
+ "Blocks identity does not match the current field body.",
305
+ );
306
+ }
307
+ return identity.blocks.map((block, index) => {
308
+ const snapshot = snapshots[index]!;
309
+ if (snapshot.kind !== block.kind) {
310
+ contractError(
311
+ "BLOCK_IDENTITY_STALE",
312
+ "Blocks identity kind does not match the current field body.",
313
+ );
314
+ }
315
+ const kind = block.kind as BlocksFieldBlockKind;
316
+ const supportedOperations = BLOCKS_FIELD_OPERATION_CAPABILITIES[kind];
317
+ return {
318
+ id: block.id,
319
+ parentId: block.parentId,
320
+ kind,
321
+ index: block.position,
322
+ addressable: block.addressable,
323
+ value: { format: "nfm", nfm: snapshot.markdown },
324
+ supportedOperations,
325
+ degraded: false,
326
+ };
327
+ });
328
+ }
329
+
330
+ function encodeCursor(revision: number, offset: number) {
331
+ return Buffer.from(JSON.stringify({ revision, offset })).toString(
332
+ "base64url",
333
+ );
334
+ }
335
+
336
+ function decodeCursor(cursor: string): { revision: number; offset: number } {
337
+ try {
338
+ const parsed = JSON.parse(
339
+ Buffer.from(cursor, "base64url").toString("utf8"),
340
+ ) as { revision?: unknown; offset?: unknown };
341
+ if (
342
+ !Number.isInteger(parsed.revision) ||
343
+ !Number.isInteger(parsed.offset) ||
344
+ (parsed.revision as number) < 0 ||
345
+ (parsed.offset as number) < 0
346
+ ) {
347
+ throw new Error("invalid");
348
+ }
349
+ return {
350
+ revision: parsed.revision as number,
351
+ offset: parsed.offset as number,
352
+ };
353
+ } catch {
354
+ contractError("INVALID_BLOCK_CURSOR", "Block cursor is invalid.", {}, 400);
355
+ }
356
+ }
357
+
358
+ export async function listDatabaseBlocks(
359
+ input: z.infer<typeof listDatabaseBlocksSchema>,
360
+ ): Promise<ContentDatabaseBlocksReadResult> {
361
+ const loaded = await loadField({ target: input.target, role: "viewer" });
362
+ if (loaded.identity.identityStatus === "stale") {
363
+ contractError(
364
+ "BLOCK_IDENTITY_STALE",
365
+ "The Blocks field changed without a matching identity revision.",
366
+ );
367
+ }
368
+ const blocks = serializedBlocks(loaded.markdown, loaded.identity);
369
+ const cursor = input.cursor ? decodeCursor(input.cursor) : null;
370
+ if (cursor && cursor.revision !== loaded.identity.revision) {
371
+ contractError(
372
+ "FIELD_REVISION_CONFLICT",
373
+ "The Blocks field changed between list pages.",
374
+ { expected: cursor.revision, actual: loaded.identity.revision },
375
+ );
376
+ }
377
+ const offset = cursor?.offset ?? 0;
378
+ if (offset > blocks.length) {
379
+ contractError(
380
+ "INVALID_BLOCK_CURSOR",
381
+ "Block cursor is out of range.",
382
+ {},
383
+ 400,
384
+ );
385
+ }
386
+ const pageBlocks = blocks.slice(offset, offset + input.limit);
387
+ const nextOffset = offset + pageBlocks.length;
388
+ return {
389
+ target: input.target,
390
+ rowLink: {
391
+ urlPath: `/page/${input.target.rowDocumentId}`,
392
+ label: "Open database row",
393
+ },
394
+ schemaRevision: loaded.context.schemaRevision,
395
+ rowRevision: loaded.row.revision,
396
+ fieldRevision: loaded.identity.revision,
397
+ identityStatus: loaded.identity.identityStatus,
398
+ total: blocks.length,
399
+ order: blocks.map((block) => block.id),
400
+ blocks: pageBlocks,
401
+ page: {
402
+ offset,
403
+ limit: input.limit,
404
+ nextCursor:
405
+ nextOffset < blocks.length
406
+ ? encodeCursor(loaded.identity.revision, nextOffset)
407
+ : null,
408
+ },
409
+ };
410
+ }
411
+
412
+ function mutationDigest(input: MutationInput) {
413
+ return digest({ contract: "content-database-block-mutation-v1", input });
414
+ }
415
+
416
+ function actionMutation(
417
+ input: MutationInput,
418
+ identity: BlocksFieldIdentity,
419
+ generatedInsertId: string,
420
+ ): { mutation: BlockDocumentMutation; insertedBlockId?: string } {
421
+ if (input.operation === "insert") {
422
+ return {
423
+ mutation: {
424
+ operation: "insert",
425
+ block: input.block,
426
+ position: input.position,
427
+ },
428
+ insertedBlockId: generatedInsertId,
429
+ };
430
+ }
431
+ if (input.operation === "upsert") {
432
+ const live = identity.blocks.find((block) => block.id === input.blockId);
433
+ if (live) {
434
+ return {
435
+ mutation: {
436
+ operation: "upsert",
437
+ blockId: input.blockId,
438
+ block: input.block,
439
+ position: input.position,
440
+ },
441
+ };
442
+ }
443
+ if (identity.tombstones.some((block) => block.id === input.blockId)) {
444
+ contractError(
445
+ "BLOCK_ID_TOMBSTONED",
446
+ "Upsert cannot silently restore a tombstoned block ID.",
447
+ { blockId: input.blockId },
448
+ );
449
+ }
450
+ if (!input.position) {
451
+ contractError(
452
+ "BLOCK_POSITION_REQUIRED",
453
+ "Upserting a new block requires an exact position.",
454
+ { blockId: input.blockId },
455
+ 400,
456
+ );
457
+ }
458
+ return {
459
+ mutation: {
460
+ operation: "insert",
461
+ block: input.block,
462
+ position: input.position,
463
+ },
464
+ insertedBlockId: input.blockId,
465
+ };
466
+ }
467
+ return { mutation: input as BlockDocumentMutation };
468
+ }
469
+
470
+ function mapMutationFailure(error: unknown): never {
471
+ if (error instanceof ActionContractError) throw error;
472
+ const message = error instanceof Error ? error.message : String(error);
473
+ const errorCode = message.includes("does not support")
474
+ ? "BLOCK_OPERATION_UNSUPPORTED"
475
+ : message.includes("Cross-parent") ||
476
+ message.includes("outside the current parent")
477
+ ? "CROSS_PARENT_REORDER_UNSUPPORTED"
478
+ : message.includes("cannot change block kind")
479
+ ? "BLOCK_KIND_MISMATCH"
480
+ : message.includes("not found")
481
+ ? "BLOCK_NOT_FOUND"
482
+ : "INVALID_BLOCK_VALUE";
483
+ contractError(
484
+ errorCode,
485
+ message,
486
+ {},
487
+ errorCode === "INVALID_BLOCK_VALUE" ? 400 : 409,
488
+ );
489
+ }
490
+
491
+ async function writeMarkdown(
492
+ db: Db,
493
+ loaded: LoadedField,
494
+ target: BlockTarget,
495
+ markdown: string,
496
+ now: string,
497
+ ) {
498
+ if (loaded.storageTarget === "document_body") {
499
+ const updated = await db
500
+ .update(schema.documents)
501
+ .set({ content: markdown, updatedAt: now })
502
+ .where(
503
+ and(
504
+ eq(schema.documents.id, target.rowDocumentId),
505
+ eq(schema.documents.content, loaded.markdown),
506
+ isNull(schema.documents.trashedAt),
507
+ ),
508
+ )
509
+ .returning({ id: schema.documents.id });
510
+ if (updated.length === 0) {
511
+ contractError(
512
+ "FIELD_REVISION_CONFLICT",
513
+ "The Blocks field changed before the mutation could commit.",
514
+ { propertyId: target.propertyId },
515
+ );
516
+ }
517
+ return;
518
+ }
519
+ await compareAndSwapAdditionalBlocksField({
520
+ db,
521
+ ownerEmail: loaded.ownerEmail,
522
+ documentId: target.rowDocumentId,
523
+ propertyId: target.propertyId,
524
+ expectedContent: loaded.markdown,
525
+ expectedExists: loaded.storageRowExists,
526
+ content: markdown,
527
+ now,
528
+ });
529
+ }
530
+
531
+ export async function compareAndSwapAdditionalBlocksField(args: {
532
+ db: ReturnType<typeof getDb>;
533
+ ownerEmail: string;
534
+ documentId: string;
535
+ propertyId: string;
536
+ expectedContent: string;
537
+ expectedExists: boolean;
538
+ content: string;
539
+ now: string;
540
+ }) {
541
+ const updated = args.expectedExists
542
+ ? await args.db
543
+ .update(schema.documentBlockFieldContents)
544
+ .set({ content: args.content, updatedAt: args.now })
545
+ .where(
546
+ and(
547
+ eq(schema.documentBlockFieldContents.documentId, args.documentId),
548
+ eq(schema.documentBlockFieldContents.propertyId, args.propertyId),
549
+ eq(schema.documentBlockFieldContents.content, args.expectedContent),
550
+ ),
551
+ )
552
+ .returning({ id: schema.documentBlockFieldContents.id })
553
+ : await args.db
554
+ .insert(schema.documentBlockFieldContents)
555
+ .values({
556
+ id: nanoid(),
557
+ ownerEmail: args.ownerEmail,
558
+ documentId: args.documentId,
559
+ propertyId: args.propertyId,
560
+ content: args.content,
561
+ createdAt: args.now,
562
+ updatedAt: args.now,
563
+ })
564
+ .onConflictDoNothing({
565
+ target: [
566
+ schema.documentBlockFieldContents.documentId,
567
+ schema.documentBlockFieldContents.propertyId,
568
+ ],
569
+ })
570
+ .returning({ id: schema.documentBlockFieldContents.id });
571
+ if (updated.length === 0) {
572
+ contractError(
573
+ "FIELD_REVISION_CONFLICT",
574
+ "The Blocks field changed before the mutation could commit.",
575
+ { propertyId: args.propertyId },
576
+ );
577
+ }
578
+ }
579
+
580
+ async function readExistingReceipt(
581
+ databaseId: string,
582
+ idempotencyKey: string,
583
+ expectedDigest: string,
584
+ db: Db = getDb(),
585
+ ): Promise<ContentDatabaseBlockMutationResult | null> {
586
+ const [stored] = await db
587
+ .select()
588
+ .from(schema.contentDatabaseRowMutationReceipts)
589
+ .where(
590
+ and(
591
+ eq(schema.contentDatabaseRowMutationReceipts.databaseId, databaseId),
592
+ eq(
593
+ schema.contentDatabaseRowMutationReceipts.idempotencyKey,
594
+ idempotencyKey,
595
+ ),
596
+ ),
597
+ );
598
+ if (!stored) return null;
599
+ if (stored.payloadDigest !== expectedDigest) {
600
+ contractError(
601
+ "IDEMPOTENCY_KEY_REUSED",
602
+ "This idempotency key was already used for a different mutation.",
603
+ { idempotencyKey },
604
+ );
605
+ }
606
+ const parsed = JSON.parse(
607
+ stored.resultJson,
608
+ ) as ContentDatabaseBlockMutationResult;
609
+ if (!parsed.receipt?.target?.propertyId) {
610
+ contractError(
611
+ "IDEMPOTENCY_KEY_REUSED",
612
+ "This idempotency key belongs to a different mutation contract.",
613
+ { idempotencyKey },
614
+ );
615
+ }
616
+ return {
617
+ receipt: {
618
+ ...parsed.receipt,
619
+ idempotency: { ...parsed.receipt.idempotency, result: "replayed" },
620
+ },
621
+ };
622
+ }
623
+
624
+ async function verifyResult(
625
+ result: ContentDatabaseBlockMutationResult,
626
+ db: Db = getDb(),
627
+ ): Promise<ContentDatabaseBlockMutationResult> {
628
+ const loaded = await loadField({
629
+ target: result.receipt.target,
630
+ role: "viewer",
631
+ db,
632
+ });
633
+ const blocks = serializedBlocks(loaded.markdown, loaded.identity);
634
+ const order = blocks.map((block) => block.id);
635
+ if (
636
+ loaded.row.revision !== result.receipt.revisions.row.after ||
637
+ loaded.identity.revision !== result.receipt.revisions.field.after ||
638
+ loaded.identity.contentHash !== result.receipt.readback.contentHash ||
639
+ JSON.stringify(order) !== JSON.stringify(result.receipt.readback.order)
640
+ ) {
641
+ contractError(
642
+ "IDEMPOTENCY_REPLAY_DRIFT",
643
+ "The committed Blocks field changed after this mutation.",
644
+ { receiptId: result.receipt.receiptId },
645
+ );
646
+ }
647
+ return {
648
+ receipt: {
649
+ ...result.receipt,
650
+ readback: {
651
+ verified: true,
652
+ fieldRevision: loaded.identity.revision,
653
+ contentHash: loaded.identity.contentHash,
654
+ order,
655
+ blocks,
656
+ },
657
+ },
658
+ };
659
+ }
660
+
661
+ function outcomeFor(
662
+ input: MutationInput,
663
+ changed: boolean,
664
+ inserted: boolean,
665
+ ): ContentDatabaseBlockMutationReceipt["outcome"] {
666
+ if (!changed) return "unchanged";
667
+ if (inserted) return "inserted";
668
+ if (input.operation === "delete") return "deleted";
669
+ if (input.operation === "reorder") return "reordered";
670
+ return "updated";
671
+ }
672
+
673
+ export async function mutateDatabaseBlock(
674
+ input: MutationInput,
675
+ ): Promise<ContentDatabaseBlockMutationResult> {
676
+ const inputDigest = mutationDigest(input);
677
+ await loadField({ target: input.target, role: "editor" });
678
+ const result = await withContentDatabaseMutationLock(
679
+ input.target.databaseId,
680
+ async () => {
681
+ const replay = await readExistingReceipt(
682
+ input.target.databaseId,
683
+ input.idempotencyKey,
684
+ inputDigest,
685
+ );
686
+ if (replay) return replay;
687
+ return getDb().transaction(async (transaction) => {
688
+ const tx = transaction as unknown as Db;
689
+ await lockContentDatabaseMutation(tx, input.target.databaseId);
690
+ const lockedReplay = await readExistingReceipt(
691
+ input.target.databaseId,
692
+ input.idempotencyKey,
693
+ inputDigest,
694
+ tx,
695
+ );
696
+ if (lockedReplay) return lockedReplay;
697
+ const primaryBlocksFields = await lockPrimaryBlocksFields(
698
+ tx,
699
+ input.target.rowDocumentId,
700
+ );
701
+ const [lockedDocument] = await tx
702
+ .update(schema.documents)
703
+ .set({ updatedAt: sql`${schema.documents.updatedAt}` })
704
+ .where(
705
+ and(
706
+ eq(schema.documents.id, input.target.rowDocumentId),
707
+ isNull(schema.documents.trashedAt),
708
+ ),
709
+ )
710
+ .returning({ id: schema.documents.id });
711
+ if (!lockedDocument) {
712
+ contractError(
713
+ "ROW_NOT_FOUND",
714
+ "The exact database row was not found.",
715
+ {
716
+ documentId: input.target.rowDocumentId,
717
+ },
718
+ 404,
719
+ );
720
+ }
721
+ const loaded = await loadField({
722
+ target: input.target,
723
+ role: "editor",
724
+ db: tx,
725
+ accessAlreadyResolved: true,
726
+ });
727
+ assertSchema(loaded.context, input.expectedSchemaRevision);
728
+ if (loaded.row.revision !== input.expectedRowRevision) {
729
+ contractError("ROW_REVISION_CONFLICT", "The database row changed.", {
730
+ expected: input.expectedRowRevision,
731
+ actual: loaded.row.revision,
732
+ });
733
+ }
734
+ if (loaded.identity.identityStatus === "stale") {
735
+ contractError(
736
+ "BLOCK_IDENTITY_STALE",
737
+ "The Blocks field changed without a matching identity revision.",
738
+ );
739
+ }
740
+ if (loaded.identity.revision !== input.expectedFieldRevision) {
741
+ contractError(
742
+ "FIELD_REVISION_CONFLICT",
743
+ "The Blocks field changed.",
744
+ {
745
+ expected: input.expectedFieldRevision,
746
+ actual: loaded.identity.revision,
747
+ },
748
+ );
749
+ }
750
+ const generatedInsertId = `block_${nanoid(16)}`;
751
+ const resolved = actionMutation(
752
+ input,
753
+ loaded.identity,
754
+ generatedInsertId,
755
+ );
756
+ if (resolved.insertedBlockId) {
757
+ const [used] = await tx
758
+ .select({ id: schema.documentBlocks.id })
759
+ .from(schema.documentBlocks)
760
+ .where(eq(schema.documentBlocks.id, resolved.insertedBlockId));
761
+ if (used) {
762
+ contractError(
763
+ "BLOCK_ID_ALREADY_USED",
764
+ "The requested block ID has already been used.",
765
+ { blockId: resolved.insertedBlockId },
766
+ );
767
+ }
768
+ }
769
+ let changed;
770
+ try {
771
+ changed = mutateBlocksFieldDocument({
772
+ markdown: loaded.markdown,
773
+ identity: loaded.identity,
774
+ mutation: resolved.mutation,
775
+ insertedBlockId: resolved.insertedBlockId,
776
+ createInsertedDescendantId: () => `block_${nanoid(16)}`,
777
+ });
778
+ } catch (error) {
779
+ mapMutationFailure(error);
780
+ }
781
+ const now = new Date().toISOString();
782
+ let postIdentity = loaded.identity;
783
+ if (changed.changed) {
784
+ await writeMarkdown(tx, loaded, input.target, changed.markdown, now);
785
+ try {
786
+ const fieldsToPersist =
787
+ loaded.storageTarget === "document_body"
788
+ ? primaryBlocksFields
789
+ : [
790
+ {
791
+ propertyId: input.target.propertyId,
792
+ ownerEmail: loaded.ownerEmail,
793
+ },
794
+ ];
795
+ if (
796
+ !fieldsToPersist.some(
797
+ (field) => field.propertyId === input.target.propertyId,
798
+ )
799
+ ) {
800
+ throw new Error(
801
+ "Primary Blocks membership changed before the operation completed.",
802
+ );
803
+ }
804
+ for (const field of fieldsToPersist) {
805
+ const isTarget = field.propertyId === input.target.propertyId;
806
+ await persistBlocksFieldIdentity({
807
+ db: tx,
808
+ ownerEmail: field.ownerEmail,
809
+ documentId: input.target.rowDocumentId,
810
+ propertyId: field.propertyId,
811
+ previousMarkdown: loaded.markdown,
812
+ markdown: changed.markdown,
813
+ ...(isTarget
814
+ ? {
815
+ expectedRevision: input.expectedFieldRevision,
816
+ preferredIdsByPath: changed.preferredIdsByPath,
817
+ rejectCrossFieldIdRemapping: true,
818
+ }
819
+ : {}),
820
+ now,
821
+ });
822
+ }
823
+ } catch (error) {
824
+ if (
825
+ error instanceof BlocksFieldIdCollisionError ||
826
+ (resolved.insertedBlockId && isUniqueConstraintError(error))
827
+ ) {
828
+ contractError(
829
+ "BLOCK_ID_ALREADY_USED",
830
+ "The requested block ID has already been used.",
831
+ {
832
+ blockId:
833
+ error instanceof BlocksFieldIdCollisionError
834
+ ? error.blockId
835
+ : resolved.insertedBlockId,
836
+ },
837
+ );
838
+ }
839
+ throw error;
840
+ }
841
+ postIdentity = await readBlocksFieldIdentity({
842
+ db: tx,
843
+ documentId: input.target.rowDocumentId,
844
+ propertyId: input.target.propertyId,
845
+ markdown: changed.markdown,
846
+ });
847
+ if (
848
+ resolved.insertedBlockId &&
849
+ !postIdentity.blocks.some(
850
+ (block) => block.id === resolved.insertedBlockId,
851
+ )
852
+ ) {
853
+ contractError(
854
+ "BLOCK_ID_ALREADY_USED",
855
+ "The requested block ID has already been used.",
856
+ { blockId: resolved.insertedBlockId },
857
+ );
858
+ }
859
+ await touchContentDatabase(tx, input.target.databaseId, now);
860
+ }
861
+ const postRow = await rowSnapshot(
862
+ tx,
863
+ input.target.databaseId,
864
+ input.target.itemId,
865
+ input.target.rowDocumentId,
866
+ revisionPropertyIds(loaded.context),
867
+ );
868
+ if (!postRow) {
869
+ contractError(
870
+ "READBACK_MISMATCH",
871
+ "The database row disappeared during block mutation.",
872
+ );
873
+ }
874
+ const postBlocks = serializedBlocks(changed.markdown, postIdentity);
875
+ const preOrder = loaded.identity.blocks.map((block) => block.id);
876
+ const postOrder = postBlocks.map((block) => block.id);
877
+ const deletedBlockIds = preOrder.filter(
878
+ (blockId) => !postOrder.includes(blockId),
879
+ );
880
+ const insertedBlockIds = postOrder.filter(
881
+ (blockId) => !preOrder.includes(blockId),
882
+ );
883
+ const primaryBlockId =
884
+ resolved.insertedBlockId ??
885
+ ("blockId" in input ? input.blockId : null);
886
+ const affectedBlockIds = [
887
+ ...new Set([
888
+ ...(primaryBlockId ? [primaryBlockId] : []),
889
+ ...insertedBlockIds,
890
+ ...deletedBlockIds,
891
+ ]),
892
+ ];
893
+ const receiptId = `block_receipt_${nanoid(18)}`;
894
+ const receipt: ContentDatabaseBlockMutationReceipt = {
895
+ receiptId,
896
+ operation: input.operation,
897
+ outcome: outcomeFor(
898
+ input,
899
+ changed.changed,
900
+ resolved.insertedBlockId !== undefined,
901
+ ),
902
+ target: input.target,
903
+ rowLink: {
904
+ urlPath: `/page/${input.target.rowDocumentId}`,
905
+ label: "Open database row",
906
+ },
907
+ schemaRevision: loaded.context.schemaRevision,
908
+ idempotency: {
909
+ key: input.idempotencyKey,
910
+ result: "applied",
911
+ payloadDigest: inputDigest,
912
+ },
913
+ revisions: {
914
+ row: { before: loaded.row.revision, after: postRow.revision },
915
+ field: {
916
+ before: loaded.identity.revision,
917
+ after: postIdentity.revision,
918
+ },
919
+ },
920
+ affected: {
921
+ blockIds: affectedBlockIds,
922
+ deletedBlockIds,
923
+ order: postOrder,
924
+ },
925
+ readback: {
926
+ verified: true,
927
+ fieldRevision: postIdentity.revision,
928
+ contentHash: postIdentity.contentHash,
929
+ order: postOrder,
930
+ blocks: postBlocks,
931
+ },
932
+ };
933
+ const storedResult: ContentDatabaseBlockMutationResult = { receipt };
934
+ await tx.insert(schema.contentDatabaseRowMutationReceipts).values({
935
+ id: receiptId,
936
+ ownerEmail: loaded.ownerEmail,
937
+ orgId: loaded.context.database.orgId,
938
+ spaceId: input.target.spaceId,
939
+ databaseId: input.target.databaseId,
940
+ databaseDocumentId: input.target.databaseDocumentId,
941
+ operation: `block:${input.operation}`,
942
+ itemId: input.target.itemId,
943
+ documentId: input.target.rowDocumentId,
944
+ idempotencyKey: input.idempotencyKey,
945
+ payloadDigest: inputDigest,
946
+ schemaRevision: loaded.context.schemaRevision,
947
+ preRowRevision: loaded.row.revision,
948
+ postRowRevision: postRow.revision,
949
+ resultJson: JSON.stringify(storedResult),
950
+ createdAt: now,
951
+ updatedAt: now,
952
+ });
953
+ return storedResult;
954
+ });
955
+ },
956
+ );
957
+ return verifyResult(result);
958
+ }