@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/actions/bigquery.ts +57 -0
- package/corpus/templates/analytics/actions/restore-analysis-revision.ts +5 -2
- package/corpus/templates/analytics/actions/restore-dashboard-revision.ts +5 -2
- package/corpus/templates/analytics/app/components/analysis/AnalysisHistoryPanel.tsx +14 -3
- package/corpus/templates/analytics/app/components/dashboard/DashboardHistoryPanel.tsx +14 -3
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +3 -0
- package/corpus/templates/analytics/app/hooks/use-analysis-revisions.ts +1 -0
- package/corpus/templates/analytics/app/hooks/use-dashboard-revisions.ts +16 -1
- package/corpus/templates/analytics/app/lib/use-auto-focus-select.ts +19 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +0 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/dashboard-sync.ts +14 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +19 -0
- package/corpus/templates/analytics/changelog/2026-08-17-fixed-dashboard-editing-focus-history-recovery-panel-setup-f.md +6 -0
- package/corpus/templates/calendar/actions/create-event.ts +7 -3
- package/corpus/templates/calendar/actions/manage-event-draft.ts +8 -0
- package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +64 -0
- package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +5 -1
- package/corpus/templates/calendar/app/hooks/use-events.ts +1 -0
- package/corpus/templates/calendar/changelog/2026-08-17-booking-links-now-respect-each-required-host-s-saved-availab.md +6 -0
- package/corpus/templates/calendar/server/lib/booking-host-availability.ts +76 -0
- package/corpus/templates/calendar/server/lib/google-calendar.ts +2 -0
- package/corpus/templates/calendar/shared/api.ts +1 -1
- package/corpus/templates/clips/app/components/player/signed-out-share-actions.tsx +47 -0
- package/corpus/templates/clips/app/components/player/transcript-panel.tsx +3 -1
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +27 -19
- package/corpus/templates/clips/changelog/2026-08-17-shared-clips-sign-in-comment-no-audio.md +6 -0
- package/corpus/templates/clips/server/routes/api/public-recording.get.ts +6 -5
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +13 -0
- package/corpus/templates/content/AGENTS.md +2 -0
- package/corpus/templates/content/actions/_blocks-field-identity.ts +17 -0
- package/corpus/templates/content/actions/_database-block-actions.ts +958 -0
- package/corpus/templates/content/actions/_database-row-mutation.ts +7 -7
- package/corpus/templates/content/actions/list-content-database-blocks.ts +31 -0
- package/corpus/templates/content/actions/mutate-content-database-block.ts +49 -0
- package/corpus/templates/content/changelog/2026-08-10-agents-can-safely-insert-update-upsert-delete-and-reorder-on.md +6 -0
- package/corpus/templates/content/docs/product/capabilities/content.object.block.md +6 -2
- package/corpus/templates/content/docs/product/capabilities/content.object.blocks-field.md +2 -1
- package/corpus/templates/content/parity/matrix.md +30 -30
- package/corpus/templates/content/parity/matrix.ts +4 -1
- package/corpus/templates/content/shared/blocks-field-identity.ts +114 -37
- package/corpus/templates/content/shared/database-block-actions.ts +82 -0
- package/corpus/templates/content/shared/database-block-mutations.ts +366 -0
- package/corpus/templates/content/shared/nfm.ts +3 -2
- package/corpus/templates/mail/.agents/skills/email-drafts/SKILL.md +24 -0
- package/corpus/templates/mail/actions/get-mail-settings.ts +1 -1
- package/corpus/templates/mail/actions/update-mail-settings.ts +7 -3
- package/corpus/templates/mail/app/i18n/en-US.ts +3 -1
- package/corpus/templates/mail/app/pages/SettingsPage.tsx +63 -0
- package/corpus/templates/mail/changelog/2026-08-17-gmail-signatures-now-preserve-images-and-support-paste-or-up.md +6 -0
- package/corpus/templates/mail/changelog/2026-08-17-mail-now-updates-durable-drafting-preferences-without-openin.md +6 -0
- package/corpus/templates/mail/server/plugins/agent-chat.ts +19 -0
- package/corpus/templates/mail/shared/gmail-signature.ts +14 -2
- package/corpus/templates/mail/shared/signature.ts +1 -10
- package/corpus/templates/slides/actions/generate-slides-ai.ts +2 -0
- package/corpus/templates/slides/actions/patch-deck.ts +1 -1
- package/corpus/templates/slides/app/components/ThemeToggle.tsx +1 -0
- package/corpus/templates/slides/app/components/editor/SlideThumbnailContextMenu.tsx +59 -0
- package/corpus/templates/slides/app/global.css +1 -0
- package/corpus/templates/slides/app/lib/normalize-slide-padding.ts +40 -11
- package/corpus/templates/slides/changelog/2026-08-17-stable-layout-and-slide-editing.md +6 -0
- package/corpus/templates/slides/shared/deck-title.ts +12 -4
- package/dist/agent/production-agent.js +17 -4
- package/dist/agent/tool-approval-migrations.d.ts +1 -0
- package/dist/agent/tool-approval-migrations.js +10 -0
- package/dist/agent/tool-approval-store.d.ts +12 -0
- package/dist/agent/tool-approval-store.js +46 -1
- package/dist/agent/types.d.ts +3 -1
- package/dist/client/AssistantChat.js +3 -1
- package/dist/localization/core-messages/ar-SA.js +1 -0
- package/dist/localization/core-messages/de-DE.js +1 -0
- package/dist/localization/core-messages/en-US.d.ts +1 -0
- package/dist/localization/core-messages/en-US.js +1 -0
- package/dist/localization/core-messages/es-ES.js +1 -0
- package/dist/localization/core-messages/fr-FR.js +1 -0
- package/dist/localization/core-messages/hi-IN.js +1 -0
- package/dist/localization/core-messages/ja-JP.js +1 -0
- package/dist/localization/core-messages/ko-KR.js +1 -0
- package/dist/localization/core-messages/pt-BR.js +1 -0
- package/dist/localization/core-messages/zh-CN.js +1 -0
- package/dist/localization/core-messages/zh-TW.js +1 -0
- package/dist/localization/core-messages.d.ts +1 -0
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/scripts/resources/read.js +4 -4
- package/dist/scripts/resources/save-memory.js +15 -2
- package/dist/scripts/resources/write.js +3 -3
- package/dist/server/agent-chat/script-entries.d.ts +1 -0
- package/dist/server/agent-chat/script-entries.js +8 -0
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/docs/content/onboarding.mdx +13 -2
- 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
|
+
}
|