@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/content/.agents/skills/content/SKILL.md +2 -2
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +20 -4
- package/corpus/templates/content/actions/_database-row-mutation.ts +1490 -0
- package/corpus/templates/content/actions/_database-utils.ts +44 -0
- package/corpus/templates/content/actions/_property-utils.ts +4 -1
- package/corpus/templates/content/actions/add-database-item.ts +62 -240
- package/corpus/templates/content/actions/configure-document-property.ts +165 -1
- package/corpus/templates/content/actions/delete-document-property.ts +18 -0
- package/corpus/templates/content/actions/set-document-property.ts +78 -1
- package/corpus/templates/content/actions/update-database-item.ts +62 -0
- package/corpus/templates/content/actions/upsert-database-item-by-key.ts +43 -796
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +10 -8
- package/corpus/templates/content/app/hooks/use-content-database.ts +33 -32
- package/corpus/templates/content/changelog/2026-08-10-database-row-actions-now-validate-exact-schemas-and-safe-ret.md +6 -0
- package/corpus/templates/content/parity/matrix.md +1 -1
- package/corpus/templates/content/parity/matrix.ts +2 -0
- package/corpus/templates/content/server/db/schema.ts +39 -3
- package/corpus/templates/content/server/plugins/db.ts +30 -0
- package/corpus/templates/content/shared/api.ts +75 -2
- package/dist/action.d.ts +22 -0
- package/dist/action.js +27 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/index.browser.d.ts +1 -1
- package/dist/index.browser.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +6 -6
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/action-routes.js +10 -2
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1490 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { ActionContractError } from "@agent-native/core";
|
|
4
|
+
import { getRequestUserEmail } from "@agent-native/core/server/request-context";
|
|
5
|
+
import { assertAccess } from "@agent-native/core/sharing";
|
|
6
|
+
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
import { getDb, schema } from "../server/db/index.js";
|
|
10
|
+
import type {
|
|
11
|
+
ContentDatabaseMutationContract,
|
|
12
|
+
ContentDatabaseRowMutationReceipt,
|
|
13
|
+
ContentDatabaseRowMutationResult,
|
|
14
|
+
} from "../shared/api.js";
|
|
15
|
+
import {
|
|
16
|
+
isBlocksPropertyType,
|
|
17
|
+
isComputedPropertyType,
|
|
18
|
+
parsePropertyOptions,
|
|
19
|
+
parsePropertyValue,
|
|
20
|
+
serializePropertyValue,
|
|
21
|
+
type DocumentPropertyDateValue,
|
|
22
|
+
type DocumentPropertyType,
|
|
23
|
+
type DocumentPropertyValue,
|
|
24
|
+
} from "../shared/properties.js";
|
|
25
|
+
import {
|
|
26
|
+
lockContentDatabaseMutation,
|
|
27
|
+
touchContentDatabase,
|
|
28
|
+
} from "./_content-database-mutation-lock.js";
|
|
29
|
+
import { ensureDocumentFilesMembership } from "./_content-files.js";
|
|
30
|
+
import {
|
|
31
|
+
databaseItemsPositionScope,
|
|
32
|
+
documentsPositionScope,
|
|
33
|
+
withPositionLock,
|
|
34
|
+
} from "./_position-utils.js";
|
|
35
|
+
import { nanoid } from "./_property-utils.js";
|
|
36
|
+
|
|
37
|
+
export const databaseMutationTargetSchema = z.object({
|
|
38
|
+
authorityScope: z.discriminatedUnion("kind", [
|
|
39
|
+
z.object({ kind: z.literal("personal"), id: z.string().min(1) }),
|
|
40
|
+
z.object({ kind: z.literal("organization"), id: z.string().min(1) }),
|
|
41
|
+
]),
|
|
42
|
+
spaceId: z.string().min(1).describe("Exact Content space ID"),
|
|
43
|
+
databaseId: z.string().min(1).describe("Exact Content database ID"),
|
|
44
|
+
databaseDocumentId: z
|
|
45
|
+
.string()
|
|
46
|
+
.min(1)
|
|
47
|
+
.describe("Exact page ID backing the Content database"),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const databaseMutationEnvelopeSchema = z.object({
|
|
51
|
+
target: databaseMutationTargetSchema,
|
|
52
|
+
expectedSchemaRevision: z
|
|
53
|
+
.string()
|
|
54
|
+
.min(1)
|
|
55
|
+
.describe("Schema revision returned by get-content-database"),
|
|
56
|
+
idempotencyKey: z.string().min(1).max(200),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export type DatabaseMutationTarget = z.infer<
|
|
60
|
+
typeof databaseMutationTargetSchema
|
|
61
|
+
>;
|
|
62
|
+
|
|
63
|
+
type DatabaseRow = typeof schema.contentDatabases.$inferSelect;
|
|
64
|
+
type DefinitionRow = typeof schema.documentPropertyDefinitions.$inferSelect;
|
|
65
|
+
type Db = ReturnType<typeof getDb>;
|
|
66
|
+
|
|
67
|
+
interface MutationContext {
|
|
68
|
+
database: DatabaseRow;
|
|
69
|
+
databaseDocument: typeof schema.documents.$inferSelect;
|
|
70
|
+
definitions: DefinitionRow[];
|
|
71
|
+
sourceManagedPropertyIds: Set<string>;
|
|
72
|
+
schemaRevision: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface RowSnapshot {
|
|
76
|
+
item: typeof schema.contentDatabaseItems.$inferSelect;
|
|
77
|
+
document: typeof schema.documents.$inferSelect;
|
|
78
|
+
values: Map<string, string>;
|
|
79
|
+
revision: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type DatabaseRowMutationOperation = "create" | "update" | "upsert";
|
|
83
|
+
|
|
84
|
+
export interface CreateDatabaseRowMutationInput {
|
|
85
|
+
target: DatabaseMutationTarget;
|
|
86
|
+
expectedSchemaRevision: string;
|
|
87
|
+
idempotencyKey: string;
|
|
88
|
+
title?: string;
|
|
89
|
+
propertyValues?: Record<string, unknown>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface UpdateDatabaseRowMutationInput extends CreateDatabaseRowMutationInput {
|
|
93
|
+
itemId: string;
|
|
94
|
+
documentId: string;
|
|
95
|
+
expectedRowRevision: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface UpsertDatabaseRowMutationInput extends CreateDatabaseRowMutationInput {
|
|
99
|
+
keyValue: string;
|
|
100
|
+
expectedRowRevision: string | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function canonical(value: unknown): string {
|
|
104
|
+
if (value === undefined) return "null";
|
|
105
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
106
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
107
|
+
return `{${Object.entries(value as Record<string, unknown>)
|
|
108
|
+
.filter(([, entry]) => entry !== undefined)
|
|
109
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
110
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`)
|
|
111
|
+
.join(",")}}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function digest(value: unknown): string {
|
|
115
|
+
return `sha256:${createHash("sha256").update(canonical(value)).digest("hex")}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function databaseRowRevision(args: {
|
|
119
|
+
itemId: string;
|
|
120
|
+
documentId: string;
|
|
121
|
+
title: string;
|
|
122
|
+
values: Array<{ propertyId: string; value: DocumentPropertyValue }>;
|
|
123
|
+
}) {
|
|
124
|
+
return digest({
|
|
125
|
+
itemId: args.itemId,
|
|
126
|
+
documentId: args.documentId,
|
|
127
|
+
title: args.title,
|
|
128
|
+
values: args.values
|
|
129
|
+
.filter((entry) => entry.value !== null)
|
|
130
|
+
.sort((left, right) => left.propertyId.localeCompare(right.propertyId)),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function conflict(
|
|
135
|
+
errorCode: string,
|
|
136
|
+
message: string,
|
|
137
|
+
details?: Record<string, unknown>,
|
|
138
|
+
): never {
|
|
139
|
+
throw new ActionContractError(message, { errorCode, details });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function invalidProperty(
|
|
143
|
+
definition: Pick<DefinitionRow, "id" | "name" | "type">,
|
|
144
|
+
reason: string,
|
|
145
|
+
): never {
|
|
146
|
+
throw new ActionContractError(
|
|
147
|
+
`Invalid value for property "${definition.name}": ${reason}`,
|
|
148
|
+
{
|
|
149
|
+
errorCode: "INVALID_PROPERTY_VALUE",
|
|
150
|
+
details: {
|
|
151
|
+
propertyId: definition.id,
|
|
152
|
+
propertyName: definition.name,
|
|
153
|
+
propertyType: definition.type,
|
|
154
|
+
reason,
|
|
155
|
+
},
|
|
156
|
+
statusCode: 400,
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function schemaPayload(
|
|
162
|
+
database: DatabaseRow,
|
|
163
|
+
definitions: DefinitionRow[],
|
|
164
|
+
sourceManagedPropertyIds: Set<string>,
|
|
165
|
+
) {
|
|
166
|
+
return {
|
|
167
|
+
naturalKeyPropertyId: database.naturalKeyPropertyId,
|
|
168
|
+
properties: definitions
|
|
169
|
+
.map((definition) => ({
|
|
170
|
+
id: definition.id,
|
|
171
|
+
name: definition.name,
|
|
172
|
+
type: definition.type,
|
|
173
|
+
description: definition.description,
|
|
174
|
+
systemRole: definition.systemRole,
|
|
175
|
+
visibility: definition.visibility,
|
|
176
|
+
options: parsePropertyOptions(definition.optionsJson),
|
|
177
|
+
sourceManaged: sourceManagedPropertyIds.has(definition.id),
|
|
178
|
+
}))
|
|
179
|
+
.sort((left, right) => left.id.localeCompare(right.id)),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function schemaRevisionFor(
|
|
184
|
+
database: DatabaseRow,
|
|
185
|
+
definitions: DefinitionRow[],
|
|
186
|
+
sourceManagedPropertyIds: Set<string>,
|
|
187
|
+
) {
|
|
188
|
+
return digest(schemaPayload(database, definitions, sourceManagedPropertyIds));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function acceptedShape(type: DocumentPropertyType): string {
|
|
192
|
+
switch (type) {
|
|
193
|
+
case "number":
|
|
194
|
+
return "finite number or null";
|
|
195
|
+
case "checkbox":
|
|
196
|
+
return "boolean or null";
|
|
197
|
+
case "multi_select":
|
|
198
|
+
return "array of option IDs or exact labels, or null";
|
|
199
|
+
case "person":
|
|
200
|
+
return "array of person identifiers, or null";
|
|
201
|
+
case "files_media":
|
|
202
|
+
return "array of http/https URLs, or null";
|
|
203
|
+
case "date":
|
|
204
|
+
return "ISO date/date-time string or { start, end?, includeTime? }, or null";
|
|
205
|
+
case "select":
|
|
206
|
+
case "status":
|
|
207
|
+
return "option ID, exact option label, or null";
|
|
208
|
+
default:
|
|
209
|
+
return "string or null";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function loadContext(
|
|
214
|
+
target: DatabaseMutationTarget,
|
|
215
|
+
role: "viewer" | "editor",
|
|
216
|
+
db: Db = getDb(),
|
|
217
|
+
accessAlreadyResolved = false,
|
|
218
|
+
): Promise<MutationContext> {
|
|
219
|
+
const [database] = await db
|
|
220
|
+
.select()
|
|
221
|
+
.from(schema.contentDatabases)
|
|
222
|
+
.where(
|
|
223
|
+
and(
|
|
224
|
+
eq(schema.contentDatabases.id, target.databaseId),
|
|
225
|
+
isNull(schema.contentDatabases.deletedAt),
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
if (!database) {
|
|
229
|
+
throw new ActionContractError("Content database not found.", {
|
|
230
|
+
errorCode: "DATABASE_NOT_FOUND",
|
|
231
|
+
statusCode: 404,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const databaseDocument = accessAlreadyResolved
|
|
235
|
+
? (
|
|
236
|
+
await db
|
|
237
|
+
.select()
|
|
238
|
+
.from(schema.documents)
|
|
239
|
+
.where(
|
|
240
|
+
and(
|
|
241
|
+
eq(schema.documents.id, database.documentId),
|
|
242
|
+
isNull(schema.documents.trashedAt),
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
)[0]
|
|
246
|
+
: (await assertAccess("document", database.documentId, role)).resource;
|
|
247
|
+
if (!databaseDocument) {
|
|
248
|
+
throw new ActionContractError("Content database backing page not found.", {
|
|
249
|
+
errorCode: "DATABASE_NOT_FOUND",
|
|
250
|
+
statusCode: 404,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (!database.spaceId) {
|
|
254
|
+
throw new ActionContractError(
|
|
255
|
+
"This database does not belong to a Content space.",
|
|
256
|
+
{ errorCode: "DATABASE_SPACE_REQUIRED", statusCode: 400 },
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const authorityScope = database.orgId
|
|
260
|
+
? { kind: "organization" as const, id: database.orgId }
|
|
261
|
+
: { kind: "personal" as const, id: database.ownerEmail };
|
|
262
|
+
if (
|
|
263
|
+
target.authorityScope.kind !== authorityScope.kind ||
|
|
264
|
+
target.authorityScope.id !== authorityScope.id ||
|
|
265
|
+
database.spaceId !== target.spaceId ||
|
|
266
|
+
database.documentId !== target.databaseDocumentId ||
|
|
267
|
+
databaseDocument.spaceId !== target.spaceId ||
|
|
268
|
+
databaseDocument.id !== target.databaseDocumentId
|
|
269
|
+
) {
|
|
270
|
+
conflict("TARGET_MISMATCH", "The Content database target tuple changed.", {
|
|
271
|
+
target,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (database.systemRole) {
|
|
275
|
+
throw new ActionContractError(
|
|
276
|
+
"Reliable row mutations are supported only for ordinary Content databases.",
|
|
277
|
+
{ errorCode: "SYSTEM_DATABASE_UNSUPPORTED", statusCode: 400 },
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const definitions = await db
|
|
281
|
+
.select()
|
|
282
|
+
.from(schema.documentPropertyDefinitions)
|
|
283
|
+
.where(eq(schema.documentPropertyDefinitions.databaseId, database.id));
|
|
284
|
+
const sourceFields = await db
|
|
285
|
+
.select({ propertyId: schema.contentDatabaseSourceFields.propertyId })
|
|
286
|
+
.from(schema.contentDatabaseSourceFields)
|
|
287
|
+
.innerJoin(
|
|
288
|
+
schema.contentDatabaseSources,
|
|
289
|
+
eq(
|
|
290
|
+
schema.contentDatabaseSources.id,
|
|
291
|
+
schema.contentDatabaseSourceFields.sourceId,
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
.where(eq(schema.contentDatabaseSources.databaseId, database.id));
|
|
295
|
+
const sourceManagedPropertyIds = new Set(
|
|
296
|
+
sourceFields.flatMap((field) =>
|
|
297
|
+
field.propertyId ? [field.propertyId] : [],
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
return {
|
|
301
|
+
database,
|
|
302
|
+
databaseDocument,
|
|
303
|
+
definitions,
|
|
304
|
+
sourceManagedPropertyIds,
|
|
305
|
+
schemaRevision: schemaRevisionFor(
|
|
306
|
+
database,
|
|
307
|
+
definitions,
|
|
308
|
+
sourceManagedPropertyIds,
|
|
309
|
+
),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function getDatabaseMutationContract(
|
|
314
|
+
target: DatabaseMutationTarget,
|
|
315
|
+
options: { accessAlreadyResolved?: boolean } = {},
|
|
316
|
+
): Promise<ContentDatabaseMutationContract> {
|
|
317
|
+
const context = await loadContext(
|
|
318
|
+
target,
|
|
319
|
+
"viewer",
|
|
320
|
+
getDb(),
|
|
321
|
+
options.accessAlreadyResolved,
|
|
322
|
+
);
|
|
323
|
+
return {
|
|
324
|
+
target: {
|
|
325
|
+
...target,
|
|
326
|
+
},
|
|
327
|
+
schemaRevision: context.schemaRevision,
|
|
328
|
+
naturalKeyPropertyId: context.database.naturalKeyPropertyId,
|
|
329
|
+
properties: context.definitions
|
|
330
|
+
.map((definition) => {
|
|
331
|
+
const type = definition.type as DocumentPropertyType;
|
|
332
|
+
const sourceManaged = context.sourceManagedPropertyIds.has(
|
|
333
|
+
definition.id,
|
|
334
|
+
);
|
|
335
|
+
const writable =
|
|
336
|
+
!definition.systemRole &&
|
|
337
|
+
!sourceManaged &&
|
|
338
|
+
type !== "relation" &&
|
|
339
|
+
!isComputedPropertyType(type) &&
|
|
340
|
+
!isBlocksPropertyType(type);
|
|
341
|
+
return {
|
|
342
|
+
id: definition.id,
|
|
343
|
+
name: definition.name,
|
|
344
|
+
type,
|
|
345
|
+
writable,
|
|
346
|
+
sourceManaged,
|
|
347
|
+
acceptedShape: writable ? acceptedShape(type) : null,
|
|
348
|
+
options: parsePropertyOptions(definition.optionsJson),
|
|
349
|
+
};
|
|
350
|
+
})
|
|
351
|
+
.sort((left, right) => left.id.localeCompare(right.id)),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function resolveOption(definition: DefinitionRow, candidate: unknown): string {
|
|
356
|
+
if (typeof candidate !== "string") {
|
|
357
|
+
invalidProperty(definition, "expected an option ID or exact label");
|
|
358
|
+
}
|
|
359
|
+
const options = parsePropertyOptions(definition.optionsJson).options ?? [];
|
|
360
|
+
const byId = options.find((option) => option.id === candidate);
|
|
361
|
+
if (byId) return byId.id;
|
|
362
|
+
const matches = options.filter((option) => option.name === candidate);
|
|
363
|
+
if (matches.length !== 1) {
|
|
364
|
+
invalidProperty(
|
|
365
|
+
definition,
|
|
366
|
+
matches.length > 1 ? "option label is ambiguous" : "unknown option",
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return matches[0]!.id;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function strictDate(
|
|
373
|
+
definition: DefinitionRow,
|
|
374
|
+
value: unknown,
|
|
375
|
+
): DocumentPropertyDateValue {
|
|
376
|
+
const date =
|
|
377
|
+
typeof value === "string"
|
|
378
|
+
? { start: value }
|
|
379
|
+
: value && typeof value === "object" && !Array.isArray(value)
|
|
380
|
+
? (value as Record<string, unknown>)
|
|
381
|
+
: null;
|
|
382
|
+
if (!date || typeof date.start !== "string") {
|
|
383
|
+
invalidProperty(definition, "expected an ISO date or date range object");
|
|
384
|
+
}
|
|
385
|
+
const start = date.start.trim();
|
|
386
|
+
const end = typeof date.end === "string" ? date.end.trim() : undefined;
|
|
387
|
+
const includeTime = date.includeTime;
|
|
388
|
+
const validIso = (candidate: string) => {
|
|
389
|
+
if (
|
|
390
|
+
!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})?)?$/.test(
|
|
391
|
+
candidate,
|
|
392
|
+
) ||
|
|
393
|
+
Number.isNaN(Date.parse(candidate))
|
|
394
|
+
) {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
const datePart = candidate.slice(0, 10);
|
|
398
|
+
return new Date(`${datePart}T00:00:00.000Z`)
|
|
399
|
+
.toISOString()
|
|
400
|
+
.startsWith(datePart);
|
|
401
|
+
};
|
|
402
|
+
if (
|
|
403
|
+
start !== date.start ||
|
|
404
|
+
(typeof date.end === "string" && end !== date.end) ||
|
|
405
|
+
!validIso(start) ||
|
|
406
|
+
(end !== undefined && !validIso(end))
|
|
407
|
+
) {
|
|
408
|
+
invalidProperty(definition, "date is not a valid ISO date/date-time");
|
|
409
|
+
}
|
|
410
|
+
if (end && Date.parse(end) < Date.parse(start)) {
|
|
411
|
+
invalidProperty(definition, "date range ends before it starts");
|
|
412
|
+
}
|
|
413
|
+
if (includeTime !== undefined && typeof includeTime !== "boolean") {
|
|
414
|
+
invalidProperty(definition, "includeTime must be boolean");
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
start,
|
|
418
|
+
...(end ? { end } : {}),
|
|
419
|
+
...(includeTime === undefined ? {} : { includeTime }),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function strictValue(
|
|
424
|
+
definition: DefinitionRow,
|
|
425
|
+
value: unknown,
|
|
426
|
+
): Promise<DocumentPropertyValue> {
|
|
427
|
+
if (value === null) return null;
|
|
428
|
+
const type = definition.type as DocumentPropertyType;
|
|
429
|
+
switch (type) {
|
|
430
|
+
case "number":
|
|
431
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
432
|
+
invalidProperty(definition, "expected a finite number");
|
|
433
|
+
return value;
|
|
434
|
+
case "checkbox":
|
|
435
|
+
if (typeof value !== "boolean")
|
|
436
|
+
invalidProperty(definition, "expected a boolean");
|
|
437
|
+
return value;
|
|
438
|
+
case "select":
|
|
439
|
+
case "status":
|
|
440
|
+
return resolveOption(definition, value);
|
|
441
|
+
case "multi_select":
|
|
442
|
+
if (!Array.isArray(value))
|
|
443
|
+
invalidProperty(definition, "expected an array of options");
|
|
444
|
+
return [
|
|
445
|
+
...new Set(
|
|
446
|
+
value.map((candidate) => resolveOption(definition, candidate)),
|
|
447
|
+
),
|
|
448
|
+
];
|
|
449
|
+
case "date":
|
|
450
|
+
return strictDate(definition, value);
|
|
451
|
+
case "person":
|
|
452
|
+
if (
|
|
453
|
+
!Array.isArray(value) ||
|
|
454
|
+
value.some(
|
|
455
|
+
(entry) =>
|
|
456
|
+
typeof entry !== "string" || !entry || entry !== entry.trim(),
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
invalidProperty(definition, "expected an array of person identifiers");
|
|
460
|
+
return [...new Set(value)];
|
|
461
|
+
case "files_media": {
|
|
462
|
+
if (
|
|
463
|
+
!Array.isArray(value) ||
|
|
464
|
+
value.some((entry) => typeof entry !== "string")
|
|
465
|
+
)
|
|
466
|
+
invalidProperty(definition, "expected an array of file URLs");
|
|
467
|
+
const urls = value.map((entry) => entry.trim());
|
|
468
|
+
if (
|
|
469
|
+
value.some((entry, index) => entry !== urls[index]) ||
|
|
470
|
+
urls.some((entry) => {
|
|
471
|
+
try {
|
|
472
|
+
const url = new URL(entry);
|
|
473
|
+
return url.protocol !== "http:" && url.protocol !== "https:";
|
|
474
|
+
} catch {
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
})
|
|
478
|
+
) {
|
|
479
|
+
invalidProperty(definition, "files must use http or https URLs");
|
|
480
|
+
}
|
|
481
|
+
return [...new Set(urls)];
|
|
482
|
+
}
|
|
483
|
+
case "url": {
|
|
484
|
+
if (typeof value !== "string" || value !== value.trim())
|
|
485
|
+
invalidProperty(definition, "expected a URL");
|
|
486
|
+
try {
|
|
487
|
+
const url = new URL(value);
|
|
488
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
489
|
+
throw new Error();
|
|
490
|
+
} catch {
|
|
491
|
+
invalidProperty(definition, "expected an http or https URL");
|
|
492
|
+
}
|
|
493
|
+
return value;
|
|
494
|
+
}
|
|
495
|
+
case "email":
|
|
496
|
+
if (
|
|
497
|
+
typeof value !== "string" ||
|
|
498
|
+
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
499
|
+
)
|
|
500
|
+
invalidProperty(definition, "expected an email address");
|
|
501
|
+
return value;
|
|
502
|
+
case "text":
|
|
503
|
+
case "place":
|
|
504
|
+
case "phone":
|
|
505
|
+
if (typeof value !== "string")
|
|
506
|
+
invalidProperty(definition, "expected a string");
|
|
507
|
+
return value;
|
|
508
|
+
default:
|
|
509
|
+
invalidProperty(definition, `property type "${type}" is not writable`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function normalizePatch(
|
|
514
|
+
context: MutationContext,
|
|
515
|
+
propertyValues: Record<string, unknown> | undefined,
|
|
516
|
+
) {
|
|
517
|
+
const definitionsById = new Map(
|
|
518
|
+
context.definitions.map((definition) => [definition.id, definition]),
|
|
519
|
+
);
|
|
520
|
+
const normalized = new Map<string, string>();
|
|
521
|
+
for (const [propertyId, input] of Object.entries(propertyValues ?? {})) {
|
|
522
|
+
const definition = definitionsById.get(propertyId);
|
|
523
|
+
if (!definition) {
|
|
524
|
+
throw new ActionContractError(`Unknown property "${propertyId}".`, {
|
|
525
|
+
errorCode: "UNKNOWN_PROPERTY",
|
|
526
|
+
details: { propertyId },
|
|
527
|
+
statusCode: 400,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
const type = definition.type as DocumentPropertyType;
|
|
531
|
+
if (
|
|
532
|
+
definition.systemRole ||
|
|
533
|
+
context.sourceManagedPropertyIds.has(propertyId) ||
|
|
534
|
+
type === "relation" ||
|
|
535
|
+
isComputedPropertyType(type) ||
|
|
536
|
+
isBlocksPropertyType(type)
|
|
537
|
+
) {
|
|
538
|
+
throw new ActionContractError(
|
|
539
|
+
`Property "${definition.name}" is not writable by database row mutations.`,
|
|
540
|
+
{
|
|
541
|
+
errorCode: "PROPERTY_NOT_WRITABLE",
|
|
542
|
+
details: { propertyId, propertyType: type },
|
|
543
|
+
statusCode: 400,
|
|
544
|
+
},
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
normalized.set(
|
|
548
|
+
propertyId,
|
|
549
|
+
serializePropertyValue(await strictValue(definition, input)),
|
|
550
|
+
);
|
|
551
|
+
if (
|
|
552
|
+
propertyId === context.database.naturalKeyPropertyId &&
|
|
553
|
+
(typeof input !== "string" || !input.trim())
|
|
554
|
+
) {
|
|
555
|
+
invalidProperty(definition, "natural key must be a non-empty string");
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return normalized;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function ensureNaturalKeyClaim(
|
|
562
|
+
db: Db,
|
|
563
|
+
context: MutationContext,
|
|
564
|
+
args: {
|
|
565
|
+
itemId: string;
|
|
566
|
+
documentId: string;
|
|
567
|
+
values: Map<string, string>;
|
|
568
|
+
now: string;
|
|
569
|
+
},
|
|
570
|
+
) {
|
|
571
|
+
const propertyId = context.database.naturalKeyPropertyId;
|
|
572
|
+
if (!propertyId) return;
|
|
573
|
+
const keyValueJson = args.values.get(propertyId);
|
|
574
|
+
if (keyValueJson === undefined) return;
|
|
575
|
+
const [existing] = await db
|
|
576
|
+
.select({
|
|
577
|
+
keyValueJson: schema.contentDatabaseItemKeyClaims.keyValueJson,
|
|
578
|
+
})
|
|
579
|
+
.from(schema.contentDatabaseItemKeyClaims)
|
|
580
|
+
.where(
|
|
581
|
+
and(
|
|
582
|
+
eq(schema.contentDatabaseItemKeyClaims.databaseId, context.database.id),
|
|
583
|
+
eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId),
|
|
584
|
+
eq(schema.contentDatabaseItemKeyClaims.documentId, args.documentId),
|
|
585
|
+
),
|
|
586
|
+
)
|
|
587
|
+
.limit(1);
|
|
588
|
+
if (existing && existing.keyValueJson !== keyValueJson) {
|
|
589
|
+
conflict(
|
|
590
|
+
"NATURAL_KEY_IMMUTABLE",
|
|
591
|
+
"A claimed database natural key cannot be changed. Create a new row instead.",
|
|
592
|
+
{ propertyId, itemId: args.itemId, documentId: args.documentId },
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
await db
|
|
596
|
+
.insert(schema.contentDatabaseItemKeyClaims)
|
|
597
|
+
.values({
|
|
598
|
+
id: nanoid(),
|
|
599
|
+
ownerEmail: context.database.ownerEmail,
|
|
600
|
+
orgId: context.database.orgId,
|
|
601
|
+
databaseId: context.database.id,
|
|
602
|
+
propertyId,
|
|
603
|
+
keyValueJson,
|
|
604
|
+
itemId: args.itemId,
|
|
605
|
+
documentId: args.documentId,
|
|
606
|
+
createdAt: args.now,
|
|
607
|
+
updatedAt: args.now,
|
|
608
|
+
})
|
|
609
|
+
.onConflictDoNothing();
|
|
610
|
+
const [claim] = await db
|
|
611
|
+
.select({
|
|
612
|
+
itemId: schema.contentDatabaseItemKeyClaims.itemId,
|
|
613
|
+
documentId: schema.contentDatabaseItemKeyClaims.documentId,
|
|
614
|
+
})
|
|
615
|
+
.from(schema.contentDatabaseItemKeyClaims)
|
|
616
|
+
.where(
|
|
617
|
+
and(
|
|
618
|
+
eq(schema.contentDatabaseItemKeyClaims.databaseId, context.database.id),
|
|
619
|
+
eq(schema.contentDatabaseItemKeyClaims.propertyId, propertyId),
|
|
620
|
+
eq(schema.contentDatabaseItemKeyClaims.keyValueJson, keyValueJson),
|
|
621
|
+
),
|
|
622
|
+
);
|
|
623
|
+
if (
|
|
624
|
+
!claim ||
|
|
625
|
+
claim.itemId !== args.itemId ||
|
|
626
|
+
claim.documentId !== args.documentId
|
|
627
|
+
) {
|
|
628
|
+
conflict("NATURAL_KEY_CONFLICT", "The natural key is already in use.", {
|
|
629
|
+
propertyId,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function rowSnapshot(
|
|
635
|
+
db: Db,
|
|
636
|
+
databaseId: string,
|
|
637
|
+
itemId: string,
|
|
638
|
+
documentId: string,
|
|
639
|
+
revisionPropertyIds: Set<string>,
|
|
640
|
+
): Promise<RowSnapshot | null> {
|
|
641
|
+
const [row] = await db
|
|
642
|
+
.select({ item: schema.contentDatabaseItems, document: schema.documents })
|
|
643
|
+
.from(schema.contentDatabaseItems)
|
|
644
|
+
.innerJoin(
|
|
645
|
+
schema.documents,
|
|
646
|
+
eq(schema.documents.id, schema.contentDatabaseItems.documentId),
|
|
647
|
+
)
|
|
648
|
+
.where(
|
|
649
|
+
and(
|
|
650
|
+
eq(schema.contentDatabaseItems.id, itemId),
|
|
651
|
+
eq(schema.contentDatabaseItems.databaseId, databaseId),
|
|
652
|
+
eq(schema.contentDatabaseItems.documentId, documentId),
|
|
653
|
+
isNull(schema.documents.trashedAt),
|
|
654
|
+
),
|
|
655
|
+
);
|
|
656
|
+
if (!row) return null;
|
|
657
|
+
const values =
|
|
658
|
+
revisionPropertyIds.size === 0
|
|
659
|
+
? []
|
|
660
|
+
: await db
|
|
661
|
+
.select()
|
|
662
|
+
.from(schema.documentPropertyValues)
|
|
663
|
+
.where(
|
|
664
|
+
and(
|
|
665
|
+
eq(schema.documentPropertyValues.documentId, documentId),
|
|
666
|
+
inArray(schema.documentPropertyValues.propertyId, [
|
|
667
|
+
...revisionPropertyIds,
|
|
668
|
+
]),
|
|
669
|
+
),
|
|
670
|
+
);
|
|
671
|
+
const valueMap = new Map(
|
|
672
|
+
values.map((value) => [value.propertyId, value.valueJson]),
|
|
673
|
+
);
|
|
674
|
+
const revision = databaseRowRevision({
|
|
675
|
+
itemId,
|
|
676
|
+
documentId,
|
|
677
|
+
title: row.document.title,
|
|
678
|
+
values: [...valueMap.entries()].map(([propertyId, valueJson]) => ({
|
|
679
|
+
propertyId,
|
|
680
|
+
value: parsePropertyValue(valueJson),
|
|
681
|
+
})),
|
|
682
|
+
});
|
|
683
|
+
return { ...row, values: valueMap, revision };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function revisionPropertyIds(context: MutationContext) {
|
|
687
|
+
return new Set(
|
|
688
|
+
context.definitions
|
|
689
|
+
.filter(
|
|
690
|
+
(definition) =>
|
|
691
|
+
!isBlocksPropertyType(definition.type as DocumentPropertyType) &&
|
|
692
|
+
!isComputedPropertyType(definition.type as DocumentPropertyType),
|
|
693
|
+
)
|
|
694
|
+
.map((definition) => definition.id),
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function payloadDigest(
|
|
699
|
+
operation: DatabaseRowMutationOperation,
|
|
700
|
+
input:
|
|
701
|
+
| CreateDatabaseRowMutationInput
|
|
702
|
+
| UpdateDatabaseRowMutationInput
|
|
703
|
+
| UpsertDatabaseRowMutationInput,
|
|
704
|
+
) {
|
|
705
|
+
return digest({ operation, ...input });
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function resultForReceipt(
|
|
709
|
+
operation: DatabaseRowMutationOperation,
|
|
710
|
+
outcome: "created" | "updated" | "unchanged",
|
|
711
|
+
context: MutationContext,
|
|
712
|
+
snapshot: RowSnapshot,
|
|
713
|
+
args: {
|
|
714
|
+
receiptId: string;
|
|
715
|
+
idempotencyKey: string;
|
|
716
|
+
payloadDigest: string;
|
|
717
|
+
preRowRevision: string | null;
|
|
718
|
+
affectedPropertyIds: string[];
|
|
719
|
+
titleAffected: boolean;
|
|
720
|
+
idempotencyResult: "applied" | "replayed";
|
|
721
|
+
},
|
|
722
|
+
): ContentDatabaseRowMutationResult {
|
|
723
|
+
const target = {
|
|
724
|
+
authorityScope: context.database.orgId
|
|
725
|
+
? ({ kind: "organization", id: context.database.orgId } as const)
|
|
726
|
+
: ({ kind: "personal", id: context.database.ownerEmail } as const),
|
|
727
|
+
spaceId: context.database.spaceId!,
|
|
728
|
+
databaseId: context.database.id,
|
|
729
|
+
databaseDocumentId: context.database.documentId,
|
|
730
|
+
};
|
|
731
|
+
const receipt: ContentDatabaseRowMutationReceipt = {
|
|
732
|
+
receiptId: args.receiptId,
|
|
733
|
+
operation,
|
|
734
|
+
outcome,
|
|
735
|
+
target,
|
|
736
|
+
schemaRevision: context.schemaRevision,
|
|
737
|
+
row: {
|
|
738
|
+
itemId: snapshot.item.id,
|
|
739
|
+
documentId: snapshot.document.id,
|
|
740
|
+
urlPath: `/page/${snapshot.document.id}`,
|
|
741
|
+
rowRevision: snapshot.revision,
|
|
742
|
+
},
|
|
743
|
+
affected: {
|
|
744
|
+
title: args.titleAffected,
|
|
745
|
+
propertyIds: args.affectedPropertyIds.sort(),
|
|
746
|
+
},
|
|
747
|
+
idempotency: {
|
|
748
|
+
key: args.idempotencyKey,
|
|
749
|
+
result: args.idempotencyResult,
|
|
750
|
+
payloadDigest: args.payloadDigest,
|
|
751
|
+
},
|
|
752
|
+
revisions: {
|
|
753
|
+
before: args.preRowRevision,
|
|
754
|
+
after: snapshot.revision,
|
|
755
|
+
},
|
|
756
|
+
readback: {
|
|
757
|
+
verified: true,
|
|
758
|
+
title: snapshot.document.title,
|
|
759
|
+
propertyValues: Object.fromEntries(
|
|
760
|
+
[...snapshot.values.entries()].map(([propertyId, valueJson]) => [
|
|
761
|
+
propertyId,
|
|
762
|
+
parsePropertyValue(valueJson),
|
|
763
|
+
]),
|
|
764
|
+
),
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
return { receipt };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function replayReceipt(
|
|
771
|
+
context: MutationContext,
|
|
772
|
+
idempotencyKey: string,
|
|
773
|
+
expectedPayloadDigest: string,
|
|
774
|
+
db: Db = getDb(),
|
|
775
|
+
): Promise<ContentDatabaseRowMutationResult | null> {
|
|
776
|
+
const [stored] = await db
|
|
777
|
+
.select()
|
|
778
|
+
.from(schema.contentDatabaseRowMutationReceipts)
|
|
779
|
+
.where(
|
|
780
|
+
and(
|
|
781
|
+
eq(
|
|
782
|
+
schema.contentDatabaseRowMutationReceipts.databaseId,
|
|
783
|
+
context.database.id,
|
|
784
|
+
),
|
|
785
|
+
eq(
|
|
786
|
+
schema.contentDatabaseRowMutationReceipts.idempotencyKey,
|
|
787
|
+
idempotencyKey,
|
|
788
|
+
),
|
|
789
|
+
),
|
|
790
|
+
);
|
|
791
|
+
if (!stored) return null;
|
|
792
|
+
if (stored.payloadDigest !== expectedPayloadDigest) {
|
|
793
|
+
conflict(
|
|
794
|
+
"IDEMPOTENCY_KEY_REUSED",
|
|
795
|
+
"This idempotency key was already used for a different row mutation.",
|
|
796
|
+
{ idempotencyKey },
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
await assertAccess("document", stored.documentId, "viewer");
|
|
800
|
+
const parsed = JSON.parse(
|
|
801
|
+
stored.resultJson,
|
|
802
|
+
) as ContentDatabaseRowMutationResult;
|
|
803
|
+
if (
|
|
804
|
+
parsed.receipt.receiptId !== stored.id ||
|
|
805
|
+
parsed.receipt.row.itemId !== stored.itemId ||
|
|
806
|
+
parsed.receipt.row.documentId !== stored.documentId ||
|
|
807
|
+
parsed.receipt.row.rowRevision !== stored.postRowRevision
|
|
808
|
+
) {
|
|
809
|
+
conflict(
|
|
810
|
+
"RECEIPT_MISMATCH",
|
|
811
|
+
"The stored database row mutation receipt is inconsistent.",
|
|
812
|
+
{ receiptId: stored.id },
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
receipt: {
|
|
817
|
+
...parsed.receipt,
|
|
818
|
+
idempotency: { ...parsed.receipt.idempotency, result: "replayed" },
|
|
819
|
+
},
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function insertReceipt(
|
|
824
|
+
db: Db,
|
|
825
|
+
context: MutationContext,
|
|
826
|
+
operation: DatabaseRowMutationOperation,
|
|
827
|
+
input: { idempotencyKey: string },
|
|
828
|
+
inputDigest: string,
|
|
829
|
+
result: ContentDatabaseRowMutationResult,
|
|
830
|
+
) {
|
|
831
|
+
const now = new Date().toISOString();
|
|
832
|
+
const receipt = result.receipt;
|
|
833
|
+
await db.insert(schema.contentDatabaseRowMutationReceipts).values({
|
|
834
|
+
id: receipt.receiptId,
|
|
835
|
+
ownerEmail: context.database.ownerEmail,
|
|
836
|
+
orgId: context.database.orgId,
|
|
837
|
+
spaceId: context.database.spaceId!,
|
|
838
|
+
databaseId: context.database.id,
|
|
839
|
+
databaseDocumentId: context.database.documentId,
|
|
840
|
+
operation,
|
|
841
|
+
itemId: receipt.row.itemId,
|
|
842
|
+
documentId: receipt.row.documentId,
|
|
843
|
+
idempotencyKey: input.idempotencyKey,
|
|
844
|
+
payloadDigest: inputDigest,
|
|
845
|
+
schemaRevision: receipt.schemaRevision,
|
|
846
|
+
preRowRevision: receipt.revisions.before,
|
|
847
|
+
postRowRevision: receipt.revisions.after,
|
|
848
|
+
resultJson: JSON.stringify(result),
|
|
849
|
+
createdAt: now,
|
|
850
|
+
updatedAt: now,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function assertSchema(context: MutationContext, expected: string) {
|
|
855
|
+
if (context.schemaRevision !== expected) {
|
|
856
|
+
conflict("SCHEMA_REVISION_CONFLICT", "The database schema changed.", {
|
|
857
|
+
expected,
|
|
858
|
+
actual: context.schemaRevision,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
async function withMutationLocks<T>(
|
|
864
|
+
database: DatabaseRow,
|
|
865
|
+
run: () => Promise<T>,
|
|
866
|
+
): Promise<T> {
|
|
867
|
+
return withPositionLock(
|
|
868
|
+
documentsPositionScope(database.ownerEmail, database.documentId),
|
|
869
|
+
() => withPositionLock(databaseItemsPositionScope(database.id), run),
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function createInsideTransaction(
|
|
874
|
+
tx: Db,
|
|
875
|
+
context: MutationContext,
|
|
876
|
+
args: {
|
|
877
|
+
title?: string;
|
|
878
|
+
values: Map<string, string>;
|
|
879
|
+
itemId?: string;
|
|
880
|
+
documentId?: string;
|
|
881
|
+
},
|
|
882
|
+
) {
|
|
883
|
+
const now = new Date().toISOString();
|
|
884
|
+
const documentId = args.documentId ?? nanoid();
|
|
885
|
+
const itemId = args.itemId ?? nanoid();
|
|
886
|
+
const [maxDoc] = await tx
|
|
887
|
+
.select({ max: sql<number>`COALESCE(MAX(position), -1)` })
|
|
888
|
+
.from(schema.documents)
|
|
889
|
+
.where(
|
|
890
|
+
and(
|
|
891
|
+
eq(schema.documents.ownerEmail, context.database.ownerEmail),
|
|
892
|
+
eq(schema.documents.parentId, context.database.documentId),
|
|
893
|
+
),
|
|
894
|
+
);
|
|
895
|
+
const [maxItem] = await tx
|
|
896
|
+
.select({ max: sql<number>`COALESCE(MAX(position), -1)` })
|
|
897
|
+
.from(schema.contentDatabaseItems)
|
|
898
|
+
.where(eq(schema.contentDatabaseItems.databaseId, context.database.id));
|
|
899
|
+
const shares = await tx
|
|
900
|
+
.select({
|
|
901
|
+
principalType: schema.documentShares.principalType,
|
|
902
|
+
principalId: schema.documentShares.principalId,
|
|
903
|
+
role: schema.documentShares.role,
|
|
904
|
+
})
|
|
905
|
+
.from(schema.documentShares)
|
|
906
|
+
.where(eq(schema.documentShares.resourceId, context.database.documentId));
|
|
907
|
+
await tx.insert(schema.documents).values({
|
|
908
|
+
id: documentId,
|
|
909
|
+
spaceId: context.database.spaceId,
|
|
910
|
+
ownerEmail: context.database.ownerEmail,
|
|
911
|
+
orgId: context.database.orgId,
|
|
912
|
+
parentId: context.database.documentId,
|
|
913
|
+
title: args.title?.trim() ?? "",
|
|
914
|
+
content: "",
|
|
915
|
+
icon: null,
|
|
916
|
+
position: (maxDoc?.max ?? -1) + 1,
|
|
917
|
+
isFavorite: 0,
|
|
918
|
+
hideFromSearch: context.databaseDocument.hideFromSearch ?? 0,
|
|
919
|
+
visibility: context.databaseDocument.visibility ?? "private",
|
|
920
|
+
createdAt: now,
|
|
921
|
+
updatedAt: now,
|
|
922
|
+
});
|
|
923
|
+
await tx.insert(schema.contentDatabaseItems).values({
|
|
924
|
+
id: itemId,
|
|
925
|
+
ownerEmail: context.database.ownerEmail,
|
|
926
|
+
orgId: context.database.orgId,
|
|
927
|
+
databaseId: context.database.id,
|
|
928
|
+
documentId,
|
|
929
|
+
position: (maxItem?.max ?? -1) + 1,
|
|
930
|
+
createdAt: now,
|
|
931
|
+
updatedAt: now,
|
|
932
|
+
});
|
|
933
|
+
if (args.values.size > 0) {
|
|
934
|
+
await tx.insert(schema.documentPropertyValues).values(
|
|
935
|
+
[...args.values.entries()].map(([propertyId, valueJson]) => ({
|
|
936
|
+
id: nanoid(),
|
|
937
|
+
ownerEmail: context.database.ownerEmail,
|
|
938
|
+
documentId,
|
|
939
|
+
propertyId,
|
|
940
|
+
valueJson,
|
|
941
|
+
createdAt: now,
|
|
942
|
+
updatedAt: now,
|
|
943
|
+
})),
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
if (shares.length > 0) {
|
|
947
|
+
await tx.insert(schema.documentShares).values(
|
|
948
|
+
shares.map((share) => ({
|
|
949
|
+
id: nanoid(),
|
|
950
|
+
resourceId: documentId,
|
|
951
|
+
principalType: share.principalType,
|
|
952
|
+
principalId: share.principalId,
|
|
953
|
+
role: share.role,
|
|
954
|
+
createdBy: getRequestUserEmail() ?? context.database.ownerEmail,
|
|
955
|
+
createdAt: now,
|
|
956
|
+
})),
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
await ensureDocumentFilesMembership(tx, documentId, now);
|
|
960
|
+
await ensureNaturalKeyClaim(tx, context, {
|
|
961
|
+
itemId,
|
|
962
|
+
documentId,
|
|
963
|
+
values: args.values,
|
|
964
|
+
now,
|
|
965
|
+
});
|
|
966
|
+
const snapshot = await rowSnapshot(
|
|
967
|
+
tx,
|
|
968
|
+
context.database.id,
|
|
969
|
+
itemId,
|
|
970
|
+
documentId,
|
|
971
|
+
revisionPropertyIds(context),
|
|
972
|
+
);
|
|
973
|
+
if (!snapshot)
|
|
974
|
+
throw new Error("Created row could not be read in its transaction.");
|
|
975
|
+
return snapshot;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async function updateInsideTransaction(
|
|
979
|
+
tx: Db,
|
|
980
|
+
context: MutationContext,
|
|
981
|
+
args: {
|
|
982
|
+
itemId: string;
|
|
983
|
+
documentId: string;
|
|
984
|
+
expectedRowRevision: string;
|
|
985
|
+
title?: string;
|
|
986
|
+
values: Map<string, string>;
|
|
987
|
+
},
|
|
988
|
+
) {
|
|
989
|
+
await assertAccess("document", args.documentId, "editor");
|
|
990
|
+
const [lockedDocument] = await tx
|
|
991
|
+
.update(schema.documents)
|
|
992
|
+
.set({ updatedAt: sql`${schema.documents.updatedAt}` })
|
|
993
|
+
.where(
|
|
994
|
+
and(
|
|
995
|
+
eq(schema.documents.id, args.documentId),
|
|
996
|
+
isNull(schema.documents.trashedAt),
|
|
997
|
+
),
|
|
998
|
+
)
|
|
999
|
+
.returning({ id: schema.documents.id });
|
|
1000
|
+
if (!lockedDocument) {
|
|
1001
|
+
throw new ActionContractError("The exact database row was not found.", {
|
|
1002
|
+
errorCode: "ROW_NOT_FOUND",
|
|
1003
|
+
statusCode: 404,
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
await tx
|
|
1007
|
+
.update(schema.contentDatabaseItems)
|
|
1008
|
+
.set({ updatedAt: sql`${schema.contentDatabaseItems.updatedAt}` })
|
|
1009
|
+
.where(
|
|
1010
|
+
and(
|
|
1011
|
+
eq(schema.contentDatabaseItems.id, args.itemId),
|
|
1012
|
+
eq(schema.contentDatabaseItems.databaseId, context.database.id),
|
|
1013
|
+
eq(schema.contentDatabaseItems.documentId, args.documentId),
|
|
1014
|
+
),
|
|
1015
|
+
);
|
|
1016
|
+
const before = await rowSnapshot(
|
|
1017
|
+
tx,
|
|
1018
|
+
context.database.id,
|
|
1019
|
+
args.itemId,
|
|
1020
|
+
args.documentId,
|
|
1021
|
+
revisionPropertyIds(context),
|
|
1022
|
+
);
|
|
1023
|
+
if (!before) {
|
|
1024
|
+
throw new ActionContractError("The exact database row was not found.", {
|
|
1025
|
+
errorCode: "ROW_NOT_FOUND",
|
|
1026
|
+
statusCode: 404,
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
if (before.revision !== args.expectedRowRevision) {
|
|
1030
|
+
conflict("ROW_REVISION_CONFLICT", "The database row changed.", {
|
|
1031
|
+
expected: args.expectedRowRevision,
|
|
1032
|
+
actual: before.revision,
|
|
1033
|
+
itemId: args.itemId,
|
|
1034
|
+
documentId: args.documentId,
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
const now = new Date().toISOString();
|
|
1038
|
+
const changedValues = [...args.values.entries()].filter(
|
|
1039
|
+
([propertyId, valueJson]) => before.values.get(propertyId) !== valueJson,
|
|
1040
|
+
);
|
|
1041
|
+
const titleChanged =
|
|
1042
|
+
args.title !== undefined && args.title.trim() !== before.document.title;
|
|
1043
|
+
if (titleChanged) {
|
|
1044
|
+
const nextUpdatedAt =
|
|
1045
|
+
now > before.document.updatedAt
|
|
1046
|
+
? now
|
|
1047
|
+
: new Date(
|
|
1048
|
+
new Date(before.document.updatedAt).getTime() + 1,
|
|
1049
|
+
).toISOString();
|
|
1050
|
+
const [updatedDocument] = await tx
|
|
1051
|
+
.update(schema.documents)
|
|
1052
|
+
.set({ title: args.title!.trim(), updatedAt: nextUpdatedAt })
|
|
1053
|
+
.where(
|
|
1054
|
+
and(
|
|
1055
|
+
eq(schema.documents.id, args.documentId),
|
|
1056
|
+
eq(schema.documents.updatedAt, before.document.updatedAt),
|
|
1057
|
+
isNull(schema.documents.trashedAt),
|
|
1058
|
+
),
|
|
1059
|
+
)
|
|
1060
|
+
.returning({ id: schema.documents.id });
|
|
1061
|
+
if (!updatedDocument) {
|
|
1062
|
+
conflict("ROW_REVISION_CONFLICT", "The database row changed.", {
|
|
1063
|
+
expected: args.expectedRowRevision,
|
|
1064
|
+
itemId: args.itemId,
|
|
1065
|
+
documentId: args.documentId,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
for (const [propertyId, valueJson] of changedValues) {
|
|
1070
|
+
const existing = before.values.get(propertyId);
|
|
1071
|
+
if (existing !== undefined) {
|
|
1072
|
+
await tx
|
|
1073
|
+
.update(schema.documentPropertyValues)
|
|
1074
|
+
.set({ valueJson, updatedAt: now })
|
|
1075
|
+
.where(
|
|
1076
|
+
and(
|
|
1077
|
+
eq(schema.documentPropertyValues.documentId, args.documentId),
|
|
1078
|
+
eq(schema.documentPropertyValues.propertyId, propertyId),
|
|
1079
|
+
),
|
|
1080
|
+
);
|
|
1081
|
+
} else {
|
|
1082
|
+
await tx.insert(schema.documentPropertyValues).values({
|
|
1083
|
+
id: nanoid(),
|
|
1084
|
+
ownerEmail: context.database.ownerEmail,
|
|
1085
|
+
documentId: args.documentId,
|
|
1086
|
+
propertyId,
|
|
1087
|
+
valueJson,
|
|
1088
|
+
createdAt: now,
|
|
1089
|
+
updatedAt: now,
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
await ensureNaturalKeyClaim(tx, context, {
|
|
1094
|
+
itemId: args.itemId,
|
|
1095
|
+
documentId: args.documentId,
|
|
1096
|
+
values: args.values,
|
|
1097
|
+
now,
|
|
1098
|
+
});
|
|
1099
|
+
const after = await rowSnapshot(
|
|
1100
|
+
tx,
|
|
1101
|
+
context.database.id,
|
|
1102
|
+
args.itemId,
|
|
1103
|
+
args.documentId,
|
|
1104
|
+
revisionPropertyIds(context),
|
|
1105
|
+
);
|
|
1106
|
+
if (!after)
|
|
1107
|
+
throw new Error("Updated row could not be read in its transaction.");
|
|
1108
|
+
return {
|
|
1109
|
+
before,
|
|
1110
|
+
after,
|
|
1111
|
+
changedPropertyIds: changedValues.map(([propertyId]) => propertyId),
|
|
1112
|
+
titleChanged,
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
export async function createDatabaseRow(
|
|
1117
|
+
input: CreateDatabaseRowMutationInput,
|
|
1118
|
+
): Promise<ContentDatabaseRowMutationResult> {
|
|
1119
|
+
const initial = await loadContext(input.target, "editor");
|
|
1120
|
+
const inputDigest = payloadDigest("create", input);
|
|
1121
|
+
const replay = await replayReceipt(
|
|
1122
|
+
initial,
|
|
1123
|
+
input.idempotencyKey,
|
|
1124
|
+
inputDigest,
|
|
1125
|
+
);
|
|
1126
|
+
if (replay) return replay;
|
|
1127
|
+
assertSchema(initial, input.expectedSchemaRevision);
|
|
1128
|
+
const values = await normalizePatch(initial, input.propertyValues);
|
|
1129
|
+
const result = await withMutationLocks(initial.database, () =>
|
|
1130
|
+
getDb().transaction(async (tx) => {
|
|
1131
|
+
await lockContentDatabaseMutation(
|
|
1132
|
+
tx as unknown as Db,
|
|
1133
|
+
initial.database.id,
|
|
1134
|
+
);
|
|
1135
|
+
const locked = await loadContext(
|
|
1136
|
+
input.target,
|
|
1137
|
+
"editor",
|
|
1138
|
+
tx as unknown as Db,
|
|
1139
|
+
);
|
|
1140
|
+
const lockedReplay = await replayReceipt(
|
|
1141
|
+
locked,
|
|
1142
|
+
input.idempotencyKey,
|
|
1143
|
+
inputDigest,
|
|
1144
|
+
tx as unknown as Db,
|
|
1145
|
+
);
|
|
1146
|
+
if (lockedReplay) return lockedReplay;
|
|
1147
|
+
assertSchema(locked, input.expectedSchemaRevision);
|
|
1148
|
+
await touchContentDatabase(
|
|
1149
|
+
tx as unknown as Db,
|
|
1150
|
+
locked.database.id,
|
|
1151
|
+
new Date().toISOString(),
|
|
1152
|
+
);
|
|
1153
|
+
const snapshot = await createInsideTransaction(
|
|
1154
|
+
tx as unknown as Db,
|
|
1155
|
+
locked,
|
|
1156
|
+
{
|
|
1157
|
+
title: input.title,
|
|
1158
|
+
values,
|
|
1159
|
+
},
|
|
1160
|
+
);
|
|
1161
|
+
const built = resultForReceipt("create", "created", locked, snapshot, {
|
|
1162
|
+
receiptId: nanoid(),
|
|
1163
|
+
idempotencyKey: input.idempotencyKey,
|
|
1164
|
+
payloadDigest: inputDigest,
|
|
1165
|
+
preRowRevision: null,
|
|
1166
|
+
affectedPropertyIds: [...values.keys()],
|
|
1167
|
+
titleAffected: input.title !== undefined,
|
|
1168
|
+
idempotencyResult: "applied",
|
|
1169
|
+
});
|
|
1170
|
+
await insertReceipt(
|
|
1171
|
+
tx as unknown as Db,
|
|
1172
|
+
locked,
|
|
1173
|
+
"create",
|
|
1174
|
+
input,
|
|
1175
|
+
inputDigest,
|
|
1176
|
+
built,
|
|
1177
|
+
);
|
|
1178
|
+
return built;
|
|
1179
|
+
}),
|
|
1180
|
+
);
|
|
1181
|
+
return result;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
export async function updateDatabaseRow(
|
|
1185
|
+
input: UpdateDatabaseRowMutationInput,
|
|
1186
|
+
): Promise<ContentDatabaseRowMutationResult> {
|
|
1187
|
+
const initial = await loadContext(input.target, "editor");
|
|
1188
|
+
await assertAccess("document", input.documentId, "editor");
|
|
1189
|
+
const inputDigest = payloadDigest("update", input);
|
|
1190
|
+
const replay = await replayReceipt(
|
|
1191
|
+
initial,
|
|
1192
|
+
input.idempotencyKey,
|
|
1193
|
+
inputDigest,
|
|
1194
|
+
);
|
|
1195
|
+
if (replay) return replay;
|
|
1196
|
+
assertSchema(initial, input.expectedSchemaRevision);
|
|
1197
|
+
const values = await normalizePatch(initial, input.propertyValues);
|
|
1198
|
+
const result = await withMutationLocks(initial.database, () =>
|
|
1199
|
+
getDb().transaction(async (tx) => {
|
|
1200
|
+
await lockContentDatabaseMutation(
|
|
1201
|
+
tx as unknown as Db,
|
|
1202
|
+
initial.database.id,
|
|
1203
|
+
);
|
|
1204
|
+
const locked = await loadContext(
|
|
1205
|
+
input.target,
|
|
1206
|
+
"editor",
|
|
1207
|
+
tx as unknown as Db,
|
|
1208
|
+
);
|
|
1209
|
+
const lockedReplay = await replayReceipt(
|
|
1210
|
+
locked,
|
|
1211
|
+
input.idempotencyKey,
|
|
1212
|
+
inputDigest,
|
|
1213
|
+
tx as unknown as Db,
|
|
1214
|
+
);
|
|
1215
|
+
if (lockedReplay) return lockedReplay;
|
|
1216
|
+
assertSchema(locked, input.expectedSchemaRevision);
|
|
1217
|
+
const updated = await updateInsideTransaction(
|
|
1218
|
+
tx as unknown as Db,
|
|
1219
|
+
locked,
|
|
1220
|
+
{
|
|
1221
|
+
itemId: input.itemId,
|
|
1222
|
+
documentId: input.documentId,
|
|
1223
|
+
expectedRowRevision: input.expectedRowRevision,
|
|
1224
|
+
title: input.title,
|
|
1225
|
+
values,
|
|
1226
|
+
},
|
|
1227
|
+
);
|
|
1228
|
+
await touchContentDatabase(
|
|
1229
|
+
tx as unknown as Db,
|
|
1230
|
+
locked.database.id,
|
|
1231
|
+
new Date().toISOString(),
|
|
1232
|
+
);
|
|
1233
|
+
const built = resultForReceipt(
|
|
1234
|
+
"update",
|
|
1235
|
+
updated.titleChanged || updated.changedPropertyIds.length > 0
|
|
1236
|
+
? "updated"
|
|
1237
|
+
: "unchanged",
|
|
1238
|
+
locked,
|
|
1239
|
+
updated.after,
|
|
1240
|
+
{
|
|
1241
|
+
receiptId: nanoid(),
|
|
1242
|
+
idempotencyKey: input.idempotencyKey,
|
|
1243
|
+
payloadDigest: inputDigest,
|
|
1244
|
+
preRowRevision: updated.before.revision,
|
|
1245
|
+
affectedPropertyIds: updated.changedPropertyIds,
|
|
1246
|
+
titleAffected: updated.titleChanged,
|
|
1247
|
+
idempotencyResult: "applied",
|
|
1248
|
+
},
|
|
1249
|
+
);
|
|
1250
|
+
await insertReceipt(
|
|
1251
|
+
tx as unknown as Db,
|
|
1252
|
+
locked,
|
|
1253
|
+
"update",
|
|
1254
|
+
input,
|
|
1255
|
+
inputDigest,
|
|
1256
|
+
built,
|
|
1257
|
+
);
|
|
1258
|
+
return built;
|
|
1259
|
+
}),
|
|
1260
|
+
);
|
|
1261
|
+
return result;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
export async function upsertDatabaseRow(
|
|
1265
|
+
input: UpsertDatabaseRowMutationInput,
|
|
1266
|
+
): Promise<ContentDatabaseRowMutationResult> {
|
|
1267
|
+
const initial = await loadContext(input.target, "editor");
|
|
1268
|
+
const replayKeyPropertyId = initial.database.naturalKeyPropertyId;
|
|
1269
|
+
const replayKeyDefinition = replayKeyPropertyId
|
|
1270
|
+
? initial.definitions.find(
|
|
1271
|
+
(definition) => definition.id === replayKeyPropertyId,
|
|
1272
|
+
)
|
|
1273
|
+
: undefined;
|
|
1274
|
+
if (
|
|
1275
|
+
replayKeyPropertyId &&
|
|
1276
|
+
replayKeyDefinition?.type === "text" &&
|
|
1277
|
+
Object.prototype.hasOwnProperty.call(
|
|
1278
|
+
input.propertyValues ?? {},
|
|
1279
|
+
replayKeyPropertyId,
|
|
1280
|
+
) &&
|
|
1281
|
+
input.propertyValues?.[replayKeyPropertyId] !== input.keyValue
|
|
1282
|
+
) {
|
|
1283
|
+
invalidProperty(
|
|
1284
|
+
replayKeyDefinition,
|
|
1285
|
+
"must match the upsert keyValue when provided in propertyValues",
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
const inputDigest = payloadDigest("upsert", input);
|
|
1289
|
+
const replay = await replayReceipt(
|
|
1290
|
+
initial,
|
|
1291
|
+
input.idempotencyKey,
|
|
1292
|
+
inputDigest,
|
|
1293
|
+
);
|
|
1294
|
+
if (replay) return replay;
|
|
1295
|
+
assertSchema(initial, input.expectedSchemaRevision);
|
|
1296
|
+
const keyPropertyId = initial.database.naturalKeyPropertyId;
|
|
1297
|
+
if (!keyPropertyId) {
|
|
1298
|
+
throw new ActionContractError(
|
|
1299
|
+
"This database has no configured natural key.",
|
|
1300
|
+
{
|
|
1301
|
+
errorCode: "NATURAL_KEY_NOT_CONFIGURED",
|
|
1302
|
+
statusCode: 400,
|
|
1303
|
+
},
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
const keyDefinition = initial.definitions.find(
|
|
1307
|
+
(definition) => definition.id === keyPropertyId,
|
|
1308
|
+
);
|
|
1309
|
+
if (!keyDefinition || keyDefinition.type !== "text") {
|
|
1310
|
+
conflict(
|
|
1311
|
+
"NATURAL_KEY_INVALID",
|
|
1312
|
+
"The configured natural key is missing or no longer a text property.",
|
|
1313
|
+
{ keyPropertyId },
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
const values = await normalizePatch(initial, {
|
|
1317
|
+
...(input.propertyValues ?? {}),
|
|
1318
|
+
[keyPropertyId]: input.keyValue,
|
|
1319
|
+
});
|
|
1320
|
+
const keyValueJson = values.get(keyPropertyId)!;
|
|
1321
|
+
const [initialClaim] = await getDb()
|
|
1322
|
+
.select({
|
|
1323
|
+
itemId: schema.contentDatabaseItemKeyClaims.itemId,
|
|
1324
|
+
documentId: schema.contentDatabaseItemKeyClaims.documentId,
|
|
1325
|
+
})
|
|
1326
|
+
.from(schema.contentDatabaseItemKeyClaims)
|
|
1327
|
+
.where(
|
|
1328
|
+
and(
|
|
1329
|
+
eq(schema.contentDatabaseItemKeyClaims.databaseId, initial.database.id),
|
|
1330
|
+
eq(schema.contentDatabaseItemKeyClaims.propertyId, keyPropertyId),
|
|
1331
|
+
eq(schema.contentDatabaseItemKeyClaims.keyValueJson, keyValueJson),
|
|
1332
|
+
),
|
|
1333
|
+
);
|
|
1334
|
+
if (initialClaim) {
|
|
1335
|
+
await assertAccess("document", initialClaim.documentId, "editor");
|
|
1336
|
+
}
|
|
1337
|
+
const result = await withMutationLocks(initial.database, () =>
|
|
1338
|
+
getDb().transaction(async (tx) => {
|
|
1339
|
+
await lockContentDatabaseMutation(
|
|
1340
|
+
tx as unknown as Db,
|
|
1341
|
+
initial.database.id,
|
|
1342
|
+
);
|
|
1343
|
+
const locked = await loadContext(
|
|
1344
|
+
input.target,
|
|
1345
|
+
"editor",
|
|
1346
|
+
tx as unknown as Db,
|
|
1347
|
+
);
|
|
1348
|
+
const lockedReplay = await replayReceipt(
|
|
1349
|
+
locked,
|
|
1350
|
+
input.idempotencyKey,
|
|
1351
|
+
inputDigest,
|
|
1352
|
+
tx as unknown as Db,
|
|
1353
|
+
);
|
|
1354
|
+
if (lockedReplay) return lockedReplay;
|
|
1355
|
+
assertSchema(locked, input.expectedSchemaRevision);
|
|
1356
|
+
if (locked.database.naturalKeyPropertyId !== keyPropertyId) {
|
|
1357
|
+
conflict("SCHEMA_REVISION_CONFLICT", "The natural key changed.");
|
|
1358
|
+
}
|
|
1359
|
+
const [claim] = await tx
|
|
1360
|
+
.select()
|
|
1361
|
+
.from(schema.contentDatabaseItemKeyClaims)
|
|
1362
|
+
.where(
|
|
1363
|
+
and(
|
|
1364
|
+
eq(
|
|
1365
|
+
schema.contentDatabaseItemKeyClaims.databaseId,
|
|
1366
|
+
locked.database.id,
|
|
1367
|
+
),
|
|
1368
|
+
eq(schema.contentDatabaseItemKeyClaims.propertyId, keyPropertyId),
|
|
1369
|
+
eq(schema.contentDatabaseItemKeyClaims.keyValueJson, keyValueJson),
|
|
1370
|
+
),
|
|
1371
|
+
);
|
|
1372
|
+
if (!claim && input.expectedRowRevision !== null) {
|
|
1373
|
+
conflict("ROW_NOT_FOUND", "No row exists for this natural key.", {
|
|
1374
|
+
keyPropertyId,
|
|
1375
|
+
keyValue: input.keyValue,
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
if (claim && input.expectedRowRevision === null) {
|
|
1379
|
+
conflict(
|
|
1380
|
+
"ROW_ALREADY_EXISTS",
|
|
1381
|
+
"A row already exists for this natural key.",
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
if (
|
|
1385
|
+
claim &&
|
|
1386
|
+
(!initialClaim ||
|
|
1387
|
+
initialClaim.itemId !== claim.itemId ||
|
|
1388
|
+
initialClaim.documentId !== claim.documentId)
|
|
1389
|
+
) {
|
|
1390
|
+
conflict(
|
|
1391
|
+
"ROW_REVISION_CONFLICT",
|
|
1392
|
+
"The natural-key row changed while the mutation was starting.",
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
if (!claim) {
|
|
1396
|
+
const itemId = nanoid();
|
|
1397
|
+
const documentId = nanoid();
|
|
1398
|
+
const now = new Date().toISOString();
|
|
1399
|
+
await tx.insert(schema.contentDatabaseItemKeyClaims).values({
|
|
1400
|
+
id: nanoid(),
|
|
1401
|
+
ownerEmail: locked.database.ownerEmail,
|
|
1402
|
+
orgId: locked.database.orgId,
|
|
1403
|
+
databaseId: locked.database.id,
|
|
1404
|
+
propertyId: keyPropertyId,
|
|
1405
|
+
keyValueJson,
|
|
1406
|
+
itemId,
|
|
1407
|
+
documentId,
|
|
1408
|
+
createdAt: now,
|
|
1409
|
+
updatedAt: now,
|
|
1410
|
+
});
|
|
1411
|
+
const snapshot = await createInsideTransaction(
|
|
1412
|
+
tx as unknown as Db,
|
|
1413
|
+
locked,
|
|
1414
|
+
{
|
|
1415
|
+
title: input.title,
|
|
1416
|
+
values,
|
|
1417
|
+
itemId,
|
|
1418
|
+
documentId,
|
|
1419
|
+
},
|
|
1420
|
+
);
|
|
1421
|
+
await touchContentDatabase(
|
|
1422
|
+
tx as unknown as Db,
|
|
1423
|
+
locked.database.id,
|
|
1424
|
+
now,
|
|
1425
|
+
);
|
|
1426
|
+
const built = resultForReceipt("upsert", "created", locked, snapshot, {
|
|
1427
|
+
receiptId: nanoid(),
|
|
1428
|
+
idempotencyKey: input.idempotencyKey,
|
|
1429
|
+
payloadDigest: inputDigest,
|
|
1430
|
+
preRowRevision: null,
|
|
1431
|
+
affectedPropertyIds: [...values.keys()],
|
|
1432
|
+
titleAffected: input.title !== undefined,
|
|
1433
|
+
idempotencyResult: "applied",
|
|
1434
|
+
});
|
|
1435
|
+
await insertReceipt(
|
|
1436
|
+
tx as unknown as Db,
|
|
1437
|
+
locked,
|
|
1438
|
+
"upsert",
|
|
1439
|
+
input,
|
|
1440
|
+
inputDigest,
|
|
1441
|
+
built,
|
|
1442
|
+
);
|
|
1443
|
+
return built;
|
|
1444
|
+
}
|
|
1445
|
+
const updated = await updateInsideTransaction(
|
|
1446
|
+
tx as unknown as Db,
|
|
1447
|
+
locked,
|
|
1448
|
+
{
|
|
1449
|
+
itemId: claim.itemId,
|
|
1450
|
+
documentId: claim.documentId,
|
|
1451
|
+
expectedRowRevision: input.expectedRowRevision!,
|
|
1452
|
+
title: input.title,
|
|
1453
|
+
values,
|
|
1454
|
+
},
|
|
1455
|
+
);
|
|
1456
|
+
await touchContentDatabase(
|
|
1457
|
+
tx as unknown as Db,
|
|
1458
|
+
locked.database.id,
|
|
1459
|
+
new Date().toISOString(),
|
|
1460
|
+
);
|
|
1461
|
+
const built = resultForReceipt(
|
|
1462
|
+
"upsert",
|
|
1463
|
+
updated.titleChanged || updated.changedPropertyIds.length > 0
|
|
1464
|
+
? "updated"
|
|
1465
|
+
: "unchanged",
|
|
1466
|
+
locked,
|
|
1467
|
+
updated.after,
|
|
1468
|
+
{
|
|
1469
|
+
receiptId: nanoid(),
|
|
1470
|
+
idempotencyKey: input.idempotencyKey,
|
|
1471
|
+
payloadDigest: inputDigest,
|
|
1472
|
+
preRowRevision: updated.before.revision,
|
|
1473
|
+
affectedPropertyIds: updated.changedPropertyIds,
|
|
1474
|
+
titleAffected: updated.titleChanged,
|
|
1475
|
+
idempotencyResult: "applied",
|
|
1476
|
+
},
|
|
1477
|
+
);
|
|
1478
|
+
await insertReceipt(
|
|
1479
|
+
tx as unknown as Db,
|
|
1480
|
+
locked,
|
|
1481
|
+
"upsert",
|
|
1482
|
+
input,
|
|
1483
|
+
inputDigest,
|
|
1484
|
+
built,
|
|
1485
|
+
);
|
|
1486
|
+
return built;
|
|
1487
|
+
}),
|
|
1488
|
+
);
|
|
1489
|
+
return result;
|
|
1490
|
+
}
|