@forge-cms/runtime 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/auth-managed.d.ts +19 -0
  2. package/dist/auth-managed.d.ts.map +1 -0
  3. package/dist/auth-managed.js +24 -0
  4. package/dist/auth-managed.js.map +1 -0
  5. package/dist/concurrency.d.ts +8 -0
  6. package/dist/concurrency.d.ts.map +1 -0
  7. package/dist/concurrency.js +15 -0
  8. package/dist/concurrency.js.map +1 -0
  9. package/dist/context.d.ts +5 -0
  10. package/dist/context.d.ts.map +1 -1
  11. package/dist/errors.d.ts +29 -1
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js +38 -0
  14. package/dist/errors.js.map +1 -1
  15. package/dist/files.d.ts +15 -1
  16. package/dist/files.d.ts.map +1 -1
  17. package/dist/files.js +55 -14
  18. package/dist/files.js.map +1 -1
  19. package/dist/globals.d.ts +21 -3
  20. package/dist/globals.d.ts.map +1 -1
  21. package/dist/globals.js +240 -31
  22. package/dist/globals.js.map +1 -1
  23. package/dist/handlers.d.ts +16 -9
  24. package/dist/handlers.d.ts.map +1 -1
  25. package/dist/handlers.js +63 -99
  26. package/dist/handlers.js.map +1 -1
  27. package/dist/index.d.ts +6 -4
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +8 -4
  30. package/dist/index.js.map +1 -1
  31. package/dist/localization.d.ts +17 -0
  32. package/dist/localization.d.ts.map +1 -1
  33. package/dist/localization.js +56 -0
  34. package/dist/localization.js.map +1 -1
  35. package/dist/operations.d.ts +89 -0
  36. package/dist/operations.d.ts.map +1 -1
  37. package/dist/operations.js +824 -116
  38. package/dist/operations.js.map +1 -1
  39. package/dist/populate.d.ts +23 -3
  40. package/dist/populate.d.ts.map +1 -1
  41. package/dist/populate.js +40 -8
  42. package/dist/populate.js.map +1 -1
  43. package/dist/read-policy.d.ts +35 -0
  44. package/dist/read-policy.d.ts.map +1 -0
  45. package/dist/read-policy.js +59 -0
  46. package/dist/read-policy.js.map +1 -0
  47. package/dist/relation-integrity.d.ts +90 -11
  48. package/dist/relation-integrity.d.ts.map +1 -1
  49. package/dist/relation-integrity.js +145 -60
  50. package/dist/relation-integrity.js.map +1 -1
  51. package/dist/relation-lifecycle.d.ts +95 -0
  52. package/dist/relation-lifecycle.d.ts.map +1 -0
  53. package/dist/relation-lifecycle.js +424 -0
  54. package/dist/relation-lifecycle.js.map +1 -0
  55. package/dist/runtime.d.ts +26 -2
  56. package/dist/runtime.d.ts.map +1 -1
  57. package/dist/runtime.js +120 -49
  58. package/dist/runtime.js.map +1 -1
  59. package/dist/storage-intents.d.ts +96 -0
  60. package/dist/storage-intents.d.ts.map +1 -0
  61. package/dist/storage-intents.js +217 -0
  62. package/dist/storage-intents.js.map +1 -0
  63. package/dist/system-fields.d.ts +27 -0
  64. package/dist/system-fields.d.ts.map +1 -0
  65. package/dist/system-fields.js +74 -0
  66. package/dist/system-fields.js.map +1 -0
  67. package/dist/typed-api.d.ts +2 -2
  68. package/dist/typed-api.d.ts.map +1 -1
  69. package/dist/versions.d.ts +76 -6
  70. package/dist/versions.d.ts.map +1 -1
  71. package/dist/versions.js +258 -44
  72. package/dist/versions.js.map +1 -1
  73. package/package.json +9 -7
@@ -1,15 +1,20 @@
1
1
  import { getLogger, validateCollection } from '@forge-cms/core';
2
- import { isUniqueConstraintError as isDbUniqueConstraintError } from '@forge-cms/db';
3
- import { AccessDeniedError, ForgeError, InvalidInputError, NotFoundError, UniqueConstraintError, ValidationFailedError } from './errors.js';
4
- import { documentMatches, mergeWhere, resolveAccess } from './access.js';
2
+ import { ATOMIC_WRITE_MAX_OPERATIONS, isAtomicWriteConditionError, isUniqueConstraintError as isDbUniqueConstraintError } from '@forge-cms/db';
3
+ import { AccessDeniedError, ConcurrentModificationError, ForgeError, InvalidInputError, NotFoundError, UniqueConstraintError, ValidationFailedError } from './errors.js';
4
+ import { documentMatches, mergeWhere } from './access.js';
5
+ import { assertNotAuthManaged } from './auth-managed.js';
5
6
  import { validateSort, validateWhere } from './query-validation.js';
6
7
  import { applyAutoSlugs, applyFieldDefaults } from './defaults.js';
8
+ import { checkAccess, statusConstraint } from './read-policy.js';
7
9
  import { runAfterChangeHooks, runAfterDeleteHooks, runAfterOperationHooks, runAfterReadHooks, runBeforeChangeHooks, runBeforeDeleteHooks, runBeforeOperationHooks, runBeforeReadHooks, runBeforeValidateHooks, runFieldHooks } from './hooks.js';
8
10
  import { assertWritableFields, filterReadableFields, FieldAccessError } from './field-access.js';
9
11
  import { populateRecord, populateRecords } from './populate.js';
10
- import { createVersion, versionsEnabled } from './versions.js';
11
- import { isLocalizedCollection, storeLocalizedDocument, resolveLocalizedDocument } from './localization.js';
12
- import { checkDeleteRestrictions, handleCascadeDelete, handleSetNullOnDelete } from './relation-integrity.js';
12
+ import { screenCreateInput, screenHookOutput, screenUpdateInput } from './system-fields.js';
13
+ import { buildSnapshot, buildVersionRecord, diffAgainst, isVersionIdentityConflict, readLatestVersionNumber, readVersionForRestore, restoreTarget, versionsCollectionSlug, versionsEnabled } from './versions.js';
14
+ import { isLocalizedCollection, isLocalizedField, storeLocalizedDocument, resolveLocalizedDocument } from './localization.js';
15
+ import { collectWrittenTargets, echoedReferenceGuard, planRelationDelete, setNullPatch, targetAssertions, verifyTargetsExist } from './relation-lifecycle.js';
16
+ import { afterStamp } from './concurrency.js';
17
+ import { claimUploadIntent, deletionIntent, finishDeletion, recordUploadIntent, settleFailedUpload } from './storage-intents.js';
13
18
  function getCollectionOrThrow(ctx, slug) {
14
19
  const collection = ctx.getCollection(slug);
15
20
  if (!collection)
@@ -36,53 +41,241 @@ async function runWrite(collection, op) {
36
41
  }
37
42
  }
38
43
  /**
39
- * Resolves the collection's access rule for an operation.
40
- *
41
- * A rule that is not configured yields `undefined`, which every caller treats as "allowed" — the
42
- * Local API has no route-level fallback to defer to, and the HTTP layer applies its own
43
- * `allowedRoles` gate *before* calling in.
44
+ * The database for a versioned write. Document + snapshot must be one atomic batch (spec 062), so an
45
+ * adapter without `atomicWrite()` is refused rather than silently downgraded to two separate writes.
46
+ * `syncSchema()` already refuses it up front; this is the same check at the point of use.
44
47
  */
45
- async function checkAccess(collection, operation, args) {
46
- if (args.overrideAccess !== false)
47
- return { allowed: true };
48
- const decision = await resolveAccess(collection.access?.[operation], {
49
- user: args.user ?? null,
50
- operation,
51
- collection,
52
- ...(args.id !== undefined && { id: args.id }),
53
- ...(args.data !== undefined && { data: args.data }),
54
- ...(args.doc !== undefined && { doc: args.doc })
55
- });
56
- if (decision === undefined)
57
- return { allowed: true };
58
- if (!decision.allowed)
59
- throw new AccessDeniedError();
60
- return decision;
48
+ function atomicDatabase(ctx, collection) {
49
+ const database = ctx.adapters.database;
50
+ if (typeof database.atomicWrite !== 'function') {
51
+ throw new Error(`Collection '${collection.slug}' has versions enabled, which requires a DatabaseAdapter ` +
52
+ `implementing atomicWrite() (specs 060/062); '${database.name}' does not.`);
53
+ }
54
+ return database;
55
+ }
56
+ /**
57
+ * A relation target named by a write was deleted after the write verified it existed (spec 064 §4): the
58
+ * batch's `assertCount` failed and nothing was written. Reported as a conflict, never as bad input —
59
+ * "the target was missing to begin with" is only ever reported from the pre-batch read.
60
+ */
61
+ function targetVanished(collection, id) {
62
+ return new ConcurrentModificationError(collection.slug, id, `A document referenced by this write to '${collection.slug}' was deleted by another request while ` +
63
+ `it was in progress; nothing was written. Reload and try again.`);
61
64
  }
62
65
  /**
63
- * The `_status` constraint for a read. Anonymous callers only ever see published documents,
64
- * whatever they ask for.
66
+ * Maps what a rolled-back document + snapshot batch threw to the runtime's typed errors (spec 062 §3).
67
+ * The version table's `(documentId, versionNumber)` index is an internal detail: on update it means
68
+ * another writer committed first (`ConcurrentModificationError`); on create it means the id is already
69
+ * taken by an existing — possibly deleted — document's history, reported like any duplicate id. The
70
+ * internal table name never reaches the caller.
65
71
  *
66
- * `defaultStatus` differs by operation, preserving spec 017's behaviour: a **list** stays
67
- * published-only unless the caller opts in (`?status=draft|all`), because a listing is the surface
68
- * that leaks unfinished content; a **single read by id** shows drafts to any authenticated caller,
69
- * since they had to know the id already.
72
+ * With relation-target assertions in the batch (spec 064) an `AtomicWriteConditionError` on create can
73
+ * only be an assertion (creates never have to apply); on update it is either an assertion or the plain
74
+ * `update` of a document deleted meanwhile — the database does not say which, so both are a `409`.
70
75
  */
71
- function statusConstraint(collection, status, user, overrideAccess, defaultStatus) {
72
- if (collection.drafts !== true)
73
- return undefined;
74
- // Trusted server-side calls see everything unless they ask for a specific status.
75
- if (overrideAccess) {
76
- if (status === undefined || status === 'all')
77
- return undefined;
78
- return { _status: status };
79
- }
80
- if (!user)
81
- return { _status: 'published' };
82
- const effective = status ?? defaultStatus;
83
- if (effective === 'all')
84
- return undefined;
85
- return { _status: effective };
76
+ function toVersionedWriteError(err, collection, id, operation, hasAssertions) {
77
+ if (isVersionIdentityConflict(err, collection)) {
78
+ return operation === 'create'
79
+ ? new UniqueConstraintError(collection.slug, ['id'])
80
+ : new ConcurrentModificationError(collection.slug, id);
81
+ }
82
+ if (isDbUniqueConstraintError(err))
83
+ return new UniqueConstraintError(collection.slug, err.fields);
84
+ if (isAtomicWriteConditionError(err)) {
85
+ if (!hasAssertions)
86
+ return notFound(collection.slug, id);
87
+ return operation === 'create'
88
+ ? targetVanished(collection, id)
89
+ : new ConcurrentModificationError(collection.slug, id, `Document '${id}' in '${collection.slug}', or a document this update references, was changed ` +
90
+ `or deleted by another request while this one was in progress; nothing was written. Reload ` +
91
+ `it and try again.`);
92
+ }
93
+ return err;
94
+ }
95
+ /**
96
+ * Versioned create (spec 062 §2): the document and its version 1 in one `atomicWrite()`, preceded by
97
+ * the write's relation-target assertions (spec 064) — one batch, never a second transaction. The batch
98
+ * is declarative and cannot feed a generated id from one operation into the next, so the id is allocated
99
+ * here — a trusted caller's explicit `id` if it supplied one (spec 063 §2), otherwise a UUID, which is
100
+ * what the adapters would have generated. `row` is what the document row persists (content plus any
101
+ * Forge-owned metadata such as `_storageKey`); the snapshot is built from content only.
102
+ */
103
+ async function createWithSnapshot(ctx, collection, input) {
104
+ const database = atomicDatabase(ctx, collection);
105
+ const id = input.id ?? crypto.randomUUID();
106
+ const { row, user, assertions } = input;
107
+ let results;
108
+ try {
109
+ results = await database.atomicWrite([
110
+ ...assertions,
111
+ { type: 'create', collection: collection.slug, data: { ...row, id } },
112
+ {
113
+ type: 'create',
114
+ collection: versionsCollectionSlug(collection.slug),
115
+ data: buildVersionRecord({
116
+ documentId: id,
117
+ versionNumber: 1,
118
+ data: buildSnapshot(collection, row),
119
+ user,
120
+ full: true
121
+ })
122
+ }
123
+ ]);
124
+ }
125
+ catch (err) {
126
+ throw toVersionedWriteError(err, collection, id, 'create', assertions.length > 0);
127
+ }
128
+ const created = results[assertions.length];
129
+ if (created?.type !== 'create')
130
+ throw new Error('atomicWrite returned no created document');
131
+ return created.record;
132
+ }
133
+ /**
134
+ * The document write and snapshot of a prepared versioned update (spec 062 §3), as batch operations.
135
+ * `versionNumber` is one past the latest version observed *before* the document was read, so if anyone
136
+ * committed in between, the snapshot collides with theirs on the unique `(documentId, versionNumber)`
137
+ * index and the whole batch — document patch included — rolls back. Shared by `update()` and by the
138
+ * set-null updates a relation delete folds into its own batch (spec 064 §5), so there is one mechanism.
139
+ */
140
+ function versionedUpdateOperations(prepared, echoGuard) {
141
+ const { collection, id, data, existing } = prepared;
142
+ return [
143
+ echoGuard === undefined
144
+ ? { type: 'update', collection: collection.slug, id, data }
145
+ : {
146
+ type: 'updateIf',
147
+ collection: collection.slug,
148
+ id,
149
+ data,
150
+ condition: { targetMatches: echoGuard },
151
+ requireApplied: true
152
+ },
153
+ {
154
+ type: 'create',
155
+ collection: versionsCollectionSlug(collection.slug),
156
+ data: buildVersionRecord({
157
+ documentId: id,
158
+ versionNumber: prepared.latestVersion + 1,
159
+ // `{ ...existing, ...data }` is exactly what the batch leaves in the row: it only commits if
160
+ // no one else wrote this document since `existing` was read.
161
+ data: buildSnapshot(collection, { ...existing, ...data }),
162
+ user: prepared.user,
163
+ full: true,
164
+ ...(prepared.versionLabel !== undefined && { label: prepared.versionLabel })
165
+ })
166
+ }
167
+ ];
168
+ }
169
+ /**
170
+ * Commits a prepared update: a versioned document with its snapshot (spec 062), and any relation-target
171
+ * assertions (spec 064) in the same single batch. A non-versioned update with nothing to assert keeps
172
+ * the plain single-call write.
173
+ */
174
+ async function commitUpdate(ctx, prepared, assertions, echoGuard) {
175
+ const { collection, id, data } = prepared;
176
+ if (prepared.versioned) {
177
+ const database = atomicDatabase(ctx, collection);
178
+ let results;
179
+ try {
180
+ results = await database.atomicWrite([
181
+ ...assertions,
182
+ ...versionedUpdateOperations(prepared, echoGuard)
183
+ ]);
184
+ }
185
+ catch (err) {
186
+ throw toVersionedWriteError(err, collection, id, 'update', assertions.length > 0 || echoGuard !== undefined);
187
+ }
188
+ const updated = results[assertions.length];
189
+ if (updated?.type === 'update' || (updated?.type === 'updateIf' && updated.applied)) {
190
+ return updated.record;
191
+ }
192
+ throw new Error('atomicWrite returned no updated document');
193
+ }
194
+ if (assertions.length === 0 && echoGuard === undefined) {
195
+ return runWrite(collection.slug, () => ctx.adapters.database.update(collection.slug, id, data));
196
+ }
197
+ // Not required to apply: a document that is gone, or no longer holds the references this update
198
+ // re-sends, is `applied: false` (nothing written), so the only thing that can fail the batch is a
199
+ // target assertion.
200
+ let results;
201
+ try {
202
+ results = await ctx.adapters.database.atomicWrite([
203
+ ...assertions,
204
+ {
205
+ type: 'updateIf',
206
+ collection: collection.slug,
207
+ id,
208
+ data,
209
+ condition: echoGuard === undefined ? {} : { targetMatches: echoGuard }
210
+ }
211
+ ]);
212
+ }
213
+ catch (err) {
214
+ if (isAtomicWriteConditionError(err))
215
+ throw targetVanished(collection, id);
216
+ if (isDbUniqueConstraintError(err))
217
+ throw new UniqueConstraintError(collection.slug, err.fields);
218
+ throw err;
219
+ }
220
+ const updated = results[assertions.length];
221
+ if (updated?.type !== 'updateIf')
222
+ throw new Error('atomicWrite returned no update result');
223
+ if (updated.applied)
224
+ return updated.record;
225
+ // Which of the two it was only picks the error: nothing was written either way.
226
+ if (!(await ctx.adapters.database.findById(collection.slug, id))) {
227
+ throw notFound(collection.slug, id);
228
+ }
229
+ throw new ConcurrentModificationError(collection.slug, id, `Document '${id}' in '${collection.slug}' was changed by another request while this update was in ` +
230
+ `progress (it no longer matches the caller's update access, another locale was edited, or a ` +
231
+ `reference this update re-sends was cleared); nothing was written. Reload it and try again.`);
232
+ }
233
+ /**
234
+ * Commits a new non-versioned document. With relation-target assertions the create joins them in one
235
+ * batch (spec 064 §4); a create never has to apply, so a failed batch condition is always an assertion.
236
+ */
237
+ async function commitCreate(ctx, collection, row, assertions) {
238
+ if (assertions.length === 0) {
239
+ return runWrite(collection.slug, () => ctx.adapters.database.create(collection.slug, row));
240
+ }
241
+ let results;
242
+ try {
243
+ results = await ctx.adapters.database.atomicWrite([
244
+ ...assertions,
245
+ { type: 'create', collection: collection.slug, data: row }
246
+ ]);
247
+ }
248
+ catch (err) {
249
+ if (isAtomicWriteConditionError(err)) {
250
+ throw targetVanished(collection, typeof row.id === 'string' ? row.id : '(new)');
251
+ }
252
+ if (isDbUniqueConstraintError(err))
253
+ throw new UniqueConstraintError(collection.slug, err.fields);
254
+ throw err;
255
+ }
256
+ const created = results[assertions.length];
257
+ if (created?.type !== 'create')
258
+ throw new Error('atomicWrite returned no created document');
259
+ return created.record;
260
+ }
261
+ /**
262
+ * Validates the relation targets a write introduces and returns the assertions that keep them valid
263
+ * until it commits (spec 064 §4). A known-missing target is a `400` here, before any write.
264
+ */
265
+ async function relationTargetGuards(ctx, collection, data, existing,
266
+ /** A create's explicit id: a self reference names the document the same batch creates. */
267
+ selfId) {
268
+ const targets = collectWrittenTargets(collection.fields, data, existing);
269
+ const self = selfId !== undefined ? targets.get(collection.slug) : undefined;
270
+ if (self && selfId !== undefined) {
271
+ self.ids.delete(selfId);
272
+ if (self.ids.size === 0)
273
+ targets.delete(collection.slug);
274
+ }
275
+ if (targets.size === 0)
276
+ return [];
277
+ await verifyTargetsExist(ctx, targets);
278
+ return targetAssertions(targets);
86
279
  }
87
280
  /**
88
281
  * Runs a stage that may reject the write. A hook throwing a plain `Error` is a rejection of the
@@ -112,7 +305,7 @@ async function prepareForRead(ctx, collection, records, args) {
112
305
  const overrideAccess = args.overrideAccess !== false;
113
306
  let docs = records;
114
307
  if (args.depth === 1) {
115
- docs = await populateRecords(docs, collection, ctx);
308
+ docs = await populateRecords(docs, collection, ctx, { user, overrideAccess });
116
309
  }
117
310
  if (args.overrideAccess === false) {
118
311
  docs = await Promise.all(docs.map((doc) => filterReadableFields(doc, collection, user)));
@@ -250,14 +443,71 @@ export async function count(ctx, args) {
250
443
  return result;
251
444
  }
252
445
  export async function create(ctx, args) {
446
+ return createDocument(ctx, args, undefined);
447
+ }
448
+ /**
449
+ * The multipart upload pipeline's create (spec 063 §5) — **package-private**: exported for
450
+ * `handlers.ts` only, never from the package entry point. `storageKey` is the key Forge itself just
451
+ * generated and stored the object under; it is merged into the persisted row only, outside caller
452
+ * `data` and hook `data`, so no caller of the public mutation surface can choose or rewrite it.
453
+ */
454
+ export async function createUpload(ctx, args, upload) {
455
+ const collection = getCollectionOrThrow(ctx, args.collection);
456
+ if (collection.upload !== true) {
457
+ throw new Error(`Collection '${args.collection}' is not upload-enabled`);
458
+ }
459
+ return createDocument(ctx, args, upload);
460
+ }
461
+ /**
462
+ * The whole upload create (spec 067) — **package-private**, called by `handleCreate` for a multipart
463
+ * body: records a durable storage intent, stores the object under a Forge-generated key, then creates
464
+ * the document with the key and the intent claim in one batch. Any failure after the intent exists
465
+ * settles it: the object is deleted only while the intent is still there (the document never
466
+ * committed); otherwise the intent stays for `reconcileStorage()`. The file's `filename`, `url`,
467
+ * `contentType` and `filesize` fill whichever of those fields the collection declares, under `args.data`.
468
+ */
469
+ export async function uploadFile(ctx, args, file) {
253
470
  const collection = getCollectionOrThrow(ctx, args.collection);
471
+ if (collection.upload !== true) {
472
+ throw new Error(`Collection '${args.collection}' is not upload-enabled`);
473
+ }
474
+ assertNotAuthManaged(ctx, args.collection);
475
+ const storageKey = `${collection.slug}/${crypto.randomUUID()}-${file.name}`;
476
+ const intentId = await recordUploadIntent(ctx.adapters.database, collection.slug, storageKey);
477
+ try {
478
+ await ctx.adapters.storage.put({ key: storageKey, body: file, contentType: file.type });
479
+ const url = await ctx.adapters.storage.getPublicUrl(storageKey);
480
+ const derived = {
481
+ filename: file.name,
482
+ url,
483
+ contentType: file.type,
484
+ filesize: file.size
485
+ };
486
+ const data = {};
487
+ for (const [name, value] of Object.entries(derived)) {
488
+ if (collection.fields[name])
489
+ data[name] = value;
490
+ }
491
+ return await createDocument(ctx, { ...args, data: { ...data, ...args.data } }, { storageKey, intentId });
492
+ }
493
+ catch (err) {
494
+ await settleFailedUpload(ctx, intentId, storageKey);
495
+ throw err;
496
+ }
497
+ }
498
+ async function createDocument(ctx, args, upload) {
499
+ const collection = getCollectionOrThrow(ctx, args.collection);
500
+ assertNotAuthManaged(ctx, args.collection);
254
501
  const user = args.user ?? null;
255
502
  const overrideAccess = args.overrideAccess !== false;
256
503
  await runBeforeOperationHooks(collection, { operation: 'create', user, overrideAccess });
257
504
  await checkAccess(collection, 'create', { ...args, data: args.data });
505
+ // Spec 063: Forge-owned metadata never comes from the caller. A trusted caller's explicit `id` is
506
+ // held aside (hooks never see or change it) and re-attached at persistence.
507
+ const { content, id: explicitId } = screenCreateInput(args.data, overrideAccess);
258
508
  if (args.overrideAccess === false) {
259
509
  try {
260
- await assertWritableFields(args.data, collection, user, 'create');
510
+ await assertWritableFields(content, collection, user, 'create');
261
511
  }
262
512
  catch (err) {
263
513
  if (err instanceof FieldAccessError)
@@ -267,7 +517,7 @@ export async function create(ctx, args) {
267
517
  }
268
518
  // Defaults and auto-slugs are resolved before any hook runs, so a hook still gets the last word
269
519
  // and validation only ever sees the final value.
270
- const seeded = applyAutoSlugs(collection, applyFieldDefaults(collection, args.data));
520
+ const seeded = applyAutoSlugs(collection, applyFieldDefaults(collection, content));
271
521
  // Process localized fields if the collection has locales configured
272
522
  const processedData = isLocalizedCollection(collection) && args.locale
273
523
  ? storeLocalizedDocument(seeded, collection, args.locale)
@@ -283,6 +533,7 @@ export async function create(ctx, args) {
283
533
  user,
284
534
  overrideAccess
285
535
  }), 'beforeValidate hook');
536
+ data = screenHookOutput(data, {}, 'beforeValidate', args.collection);
286
537
  assertDraftStatus(collection, data);
287
538
  if (collection.drafts === true && data._status === undefined) {
288
539
  data = { ...data, _status: 'draft' };
@@ -301,16 +552,19 @@ export async function create(ctx, args) {
301
552
  user,
302
553
  overrideAccess
303
554
  }), 'beforeChange hook');
304
- const record = await runWrite(args.collection, () => ctx.adapters.database.create(args.collection, data));
305
- // Create initial version if versions are enabled
306
- if (versionsEnabled(collection)) {
307
- await createVersion(ctx, {
308
- collection: args.collection,
309
- documentId: record.id,
310
- data,
311
- user
312
- });
313
- }
555
+ data = screenHookOutput(data, {}, 'beforeChange', args.collection);
556
+ // Every relation target this document names must exist, now and when it commits (spec 064 §4).
557
+ const assertions = await relationTargetGuards(ctx, collection, data, undefined, explicitId);
558
+ // An upload's storage intent is removed in the same batch that creates its owner (spec 067), so the
559
+ // intent exists exactly as long as the object is owned by nothing.
560
+ if (upload)
561
+ assertions.push(claimUploadIntent(upload.intentId));
562
+ // Forge-owned metadata joins the content only here, at the persistence boundary (spec 063 §4/§5).
563
+ const row = upload !== undefined ? { ...data, _storageKey: upload.storageKey } : data;
564
+ // A versioned document and its version 1 commit together or not at all (spec 062 §2).
565
+ const record = versionsEnabled(collection)
566
+ ? await createWithSnapshot(ctx, collection, { row, id: explicitId, user, assertions })
567
+ : await commitCreate(ctx, collection, explicitId !== undefined ? { ...row, id: explicitId } : row, assertions);
314
568
  await runAfterChangeHooks(collection, {
315
569
  operation: 'create',
316
570
  data,
@@ -319,31 +573,69 @@ export async function create(ctx, args) {
319
573
  user,
320
574
  overrideAccess
321
575
  });
322
- const [doc] = await prepareForRead(ctx, collection, [record], args);
323
- const result = doc ?? record;
576
+ const result = await writeResult(ctx, collection, record, args);
324
577
  await runAfterOperationHooks(collection, { operation: 'create', user, overrideAccess, result });
325
578
  return result;
326
579
  }
327
580
  export async function update(ctx, args) {
581
+ return updateDocument(ctx, args);
582
+ }
583
+ /**
584
+ * The update pipeline up to — never including — the write: `beforeOperation`, version-number read (spec
585
+ * 062 §3: before the document), document read, access, system-field screening, field-write access,
586
+ * locale storage, `beforeValidate`, validation, `beforeChange`. One implementation for `update()`,
587
+ * `restoreVersion()` and relation set-null (spec 064), so none of them can drift.
588
+ *
589
+ * `restore` (only ever passed by {@link restoreVersion}) replaces `args.data` with the difference between
590
+ * that version's content and the document as read *here* — after the version number was observed — so a
591
+ * restore is serialized exactly like any other update (spec 062 §6). `patch` computes the request from
592
+ * the document as read here (relation set-null); returning `null` means there is nothing to change, and
593
+ * the preparation stops (`null`) before any further hook.
594
+ */
595
+ async function prepareUpdate(ctx, args, options = {}) {
328
596
  const collection = getCollectionOrThrow(ctx, args.collection);
597
+ assertNotAuthManaged(ctx, args.collection);
329
598
  const user = args.user ?? null;
330
599
  const overrideAccess = args.overrideAccess !== false;
331
600
  await runBeforeOperationHooks(collection, { operation: 'update', user, overrideAccess });
601
+ // Spec 062 §3: the latest version number is read BEFORE the document. A write that commits version
602
+ // N+1 therefore proves nobody committed between this observation and the document read below — the
603
+ // unique (documentId, versionNumber) index turns any such interleaving into a rolled-back conflict.
604
+ const versioned = versionsEnabled(collection);
605
+ const latestVersion = versioned
606
+ ? await readLatestVersionNumber(ctx.adapters.database, collection, args.id)
607
+ : 0;
332
608
  const existing = await ctx.adapters.database.findById(args.collection, args.id);
333
609
  if (!existing)
334
610
  throw notFound(args.collection, args.id);
611
+ let requested;
612
+ if (options.restore) {
613
+ requested = diffAgainst(restoreTarget(collection, options.restore), existing);
614
+ }
615
+ else if (options.patch) {
616
+ const patch = options.patch(existing);
617
+ if (patch === null)
618
+ return null;
619
+ requested = patch;
620
+ }
621
+ else {
622
+ requested = args.data;
623
+ }
335
624
  const decision = await checkAccess(collection, 'update', {
336
625
  ...args,
337
626
  id: args.id,
338
- data: args.data,
627
+ data: requested,
339
628
  doc: existing
340
629
  });
341
630
  if (decision.where && !documentMatches(existing, decision.where)) {
342
631
  throw new AccessDeniedError();
343
632
  }
633
+ // Spec 063: echoes of the stored metadata are dropped; changing `id`, a timestamp or `_storageKey`
634
+ // is refused for every caller, `overrideAccess` included.
635
+ const input = screenUpdateInput(requested, existing);
344
636
  if (args.overrideAccess === false) {
345
637
  try {
346
- await assertWritableFields(args.data, collection, user, 'update');
638
+ await assertWritableFields(input, collection, user, 'update');
347
639
  }
348
640
  catch (err) {
349
641
  if (err instanceof FieldAccessError)
@@ -353,8 +645,8 @@ export async function update(ctx, args) {
353
645
  }
354
646
  // Process localized fields if the collection has locales configured
355
647
  const processedData = isLocalizedCollection(collection) && args.locale
356
- ? storeLocalizedDocument(args.data, collection, args.locale, existing)
357
- : args.data;
648
+ ? storeLocalizedDocument(input, collection, args.locale, existing)
649
+ : input;
358
650
  let data = await runRejectableStage(async () => runBeforeValidateHooks(collection, {
359
651
  operation: 'update',
360
652
  data: await runFieldHooks(collection, 'beforeValidate', {
@@ -368,6 +660,7 @@ export async function update(ctx, args) {
368
660
  user,
369
661
  overrideAccess
370
662
  }), 'beforeValidate hook');
663
+ data = screenHookOutput(data, existing, 'beforeValidate', args.collection);
371
664
  assertDraftStatus(collection, data);
372
665
  // Validate the merged document so required fields already stored do not fail a partial update,
373
666
  // then report only the errors the caller can actually act on: fields they are touching, or fields
@@ -395,16 +688,24 @@ export async function update(ctx, args) {
395
688
  user,
396
689
  overrideAccess
397
690
  }), 'beforeChange hook');
398
- const record = await runWrite(args.collection, () => ctx.adapters.database.update(args.collection, args.id, data));
399
- // Create a version snapshot if versions are enabled
400
- if (versionsEnabled(collection)) {
401
- await createVersion(ctx, {
402
- collection: args.collection,
403
- documentId: args.id,
404
- data,
405
- user
406
- });
407
- }
691
+ data = screenHookOutput(data, existing, 'beforeChange', args.collection);
692
+ return {
693
+ collection,
694
+ id: args.id,
695
+ args,
696
+ user,
697
+ overrideAccess,
698
+ existing,
699
+ data,
700
+ versioned,
701
+ latestVersion,
702
+ versionLabel: args.versionLabel,
703
+ accessWhere: decision.where
704
+ };
705
+ }
706
+ /** The post-commit half of an update: `afterChange`, the read pipeline, `afterOperation`. */
707
+ async function finalizeUpdate(ctx, prepared, record) {
708
+ const { collection, data, existing, user, overrideAccess } = prepared;
408
709
  await runAfterChangeHooks(collection, {
409
710
  operation: 'update',
410
711
  data,
@@ -414,31 +715,164 @@ export async function update(ctx, args) {
414
715
  user,
415
716
  overrideAccess
416
717
  });
417
- const [doc] = await prepareForRead(ctx, collection, [record], args);
418
- const result = doc ?? record;
718
+ const result = await writeResult(ctx, collection, record, prepared.args);
419
719
  await runAfterOperationHooks(collection, { operation: 'update', user, overrideAccess, result });
420
720
  return result;
421
721
  }
422
- const MEDIA_URL_PREFIX = '/api/media/';
722
+ async function updateDocument(ctx, args, restore) {
723
+ const prepared = await prepareUpdate(ctx, args, restore ? { restore } : {});
724
+ if (!prepared)
725
+ throw new Error('update preparation produced no change'); // unreachable without `patch`
726
+ // Only relation values this update changes are validated (spec 064 §4) — a partial update never
727
+ // fails over a reference it does not touch.
728
+ const assertions = await relationTargetGuards(ctx, prepared.collection, prepared.data, prepared.existing);
729
+ // …and the references it re-sends unchanged must still be there when it commits.
730
+ const echoGuard = echoedReferenceGuard(prepared.collection.fields, prepared.data, prepared.existing);
731
+ // A locale write merged into the stored per-locale maps must not overwrite a concurrent edit of
732
+ // another locale: it commits only while the row is still the one it merged from (spec 067).
733
+ const localeGuard = localeMergeGuard(prepared);
734
+ if (localeGuard)
735
+ await afterStamp(prepared.existing.updated_at);
736
+ // A query-returning update rule is a row-level grant: the row must still match it when the write
737
+ // commits, not only when it was read (spec 068).
738
+ const guard = allOf([echoGuard, localeGuard, prepared.accessWhere]);
739
+ const record = await commitUpdate(ctx, prepared, assertions, guard);
740
+ return finalizeUpdate(ctx, prepared, record);
741
+ }
742
+ /**
743
+ * What a write returns (spec 068). A trusted write (or a caller who may read the result) gets the
744
+ * document through the normal read preparation (field access, locale, population, afterRead hooks). An
745
+ * access-checked caller whose *read* access does not reach the written document — the collection's read
746
+ * rule denies it, its query does not match, or it is a draft they could not read — gets only its `id`:
747
+ * being allowed to write a document is not permission to read it back.
748
+ */
749
+ async function writeResult(ctx, collection, record, args) {
750
+ if (args.overrideAccess === false && !(await canRead(collection, record, args.user ?? null))) {
751
+ return { id: record.id };
752
+ }
753
+ const [doc] = await prepareForRead(ctx, collection, [record], args);
754
+ return doc ?? record;
755
+ }
756
+ /** The single-document read gate `findByID` applies, as a yes/no for an already-loaded row. */
757
+ async function canRead(collection, record, user) {
758
+ let decision;
759
+ try {
760
+ // Exactly `findByID`'s arguments — no `doc`, so a read rule answers the same here as there.
761
+ decision = await checkAccess(collection, 'read', {
762
+ user,
763
+ overrideAccess: false,
764
+ id: record.id
765
+ });
766
+ }
767
+ catch (err) {
768
+ if (err instanceof AccessDeniedError)
769
+ return false;
770
+ throw err;
771
+ }
772
+ if (decision.where && !documentMatches(record, decision.where))
773
+ return false;
774
+ const status = statusConstraint(collection, undefined, user, false, 'all');
775
+ return !status || documentMatches(record, status);
776
+ }
777
+ /** The conjunction of the defined conditions, or `undefined` when there are none. */
778
+ function allOf(conditions) {
779
+ const defined = conditions.filter((c) => c !== undefined);
780
+ if (defined.length === 0)
781
+ return undefined;
782
+ return defined.length === 1 ? defined[0] : { and: defined };
783
+ }
784
+ /** `updated_at` compare-and-set for an update that merged a `locale` into stored per-locale maps. */
785
+ function localeMergeGuard(prepared) {
786
+ const { collection, args, data, existing } = prepared;
787
+ if (args.locale === undefined || !isLocalizedCollection(collection))
788
+ return undefined;
789
+ if (typeof existing.updated_at !== 'string')
790
+ return undefined;
791
+ const merges = Object.keys(data).some((name) => {
792
+ const field = collection.fields[name];
793
+ return field !== undefined && isLocalizedField(field);
794
+ });
795
+ return merges ? { updated_at: existing.updated_at } : undefined;
796
+ }
423
797
  /**
424
- * Resolves the storage key a stored upload's underlying object lives under: the `_storageKey` every
425
- * upload-created document carries, or (for an older/manually-created record without one) a fallback
426
- * parsed from its `url`, matching the default `/api/media/<collection>/<key>` shape `handleFile` and
427
- * every `StorageAdapter`'s default `getPublicUrl` use.
798
+ * Restores a document to a specific historical version — spec 058 §2, made atomic by spec 062. The raw
799
+ * snapshot is read (trusted — restore's authorization gate is `update()`'s own update-access check on
800
+ * the *current* document, see spec 058 §2) and handed to this module's own update pipeline, which turns
801
+ * it into a patch of only the fields that differ from the current document (spec 062 §6) and then runs
802
+ * the normal update-access / row-policy / field-write / validation / hook pipeline. The patch and one
803
+ * snapshot labeled `Restored from version N` commit in one atomic batch, so a forbidden, invalid or
804
+ * conflicting restore leaves both the document and its history unchanged.
805
+ *
806
+ * System metadata (`id`, `created_at`, `updated_at`, `_storageKey`) is never restored. A full (spec 062)
807
+ * snapshot sets every currently declared field, so a snapshot that predates a now-required field fails
808
+ * current validation instead of producing an invalid document.
428
809
  */
429
- function resolveStorageKey(collectionSlug, doc) {
810
+ export async function restoreVersion(ctx, args) {
811
+ const collection = getCollectionOrThrow(ctx, args.collection);
812
+ // A restore is an update; refuse it before reading the snapshot so the answer never depends on it.
813
+ assertNotAuthManaged(ctx, args.collection);
814
+ if (!versionsEnabled(collection)) {
815
+ throw new Error(`Collection '${args.collection}' does not have versions enabled`);
816
+ }
817
+ const restorable = await readVersionForRestore(ctx, collection, args.versionId);
818
+ return updateDocument(ctx, {
819
+ collection: args.collection,
820
+ id: restorable.version.documentId,
821
+ data: {},
822
+ ...(args.user !== undefined && { user: args.user }),
823
+ ...(args.overrideAccess !== undefined && { overrideAccess: args.overrideAccess }),
824
+ versionLabel: `Restored from version ${restorable.version.versionNumber}`
825
+ }, restorable);
826
+ }
827
+ /**
828
+ * The storage object a deleted upload document owns: only the `_storageKey` Forge's own upload
829
+ * pipeline recorded (spec 063 §6). There is deliberately no fallback derived from `url` — that is a
830
+ * declared, caller-writable content field, and deleting whatever it points at let any caller with
831
+ * create/update + delete access destroy another document's object.
832
+ */
833
+ function ownedStorageKey(doc) {
430
834
  const storageKey = doc._storageKey;
431
- if (typeof storageKey === 'string' && storageKey.length > 0)
432
- return storageKey;
433
- const url = doc.url;
434
- if (typeof url !== 'string')
435
- return null;
436
- const prefix = `${MEDIA_URL_PREFIX}${collectionSlug}/`;
437
- const idx = url.indexOf(prefix);
438
- return idx === -1 ? null : url.slice(idx + MEDIA_URL_PREFIX.length);
835
+ return typeof storageKey === 'string' && storageKey.length > 0 ? storageKey : null;
439
836
  }
837
+ /**
838
+ * Compare-and-set against the document a relation delete planned with (spec 064 §5): it is only
839
+ * written if nobody changed it since. A row without `updated_at` (written outside the pipeline) can only
840
+ * be guarded by existence. Millisecond precision — see the spec's concurrency notes.
841
+ */
842
+ function unchangedSince(doc) {
843
+ return typeof doc.updated_at === 'string'
844
+ ? { targetMatches: { updated_at: doc.updated_at } }
845
+ : {};
846
+ }
847
+ function warnNoStorageKey(collection, doc) {
848
+ getLogger().warn?.(`Upload document '${collection.slug}/${String(doc.id)}' has no Forge-recorded storage key; no ` +
849
+ `storage object will be deleted (spec 063 §6)`);
850
+ }
851
+ /**
852
+ * Deletes a document together with its whole relation graph (spec 064 §5) — plan, prepare, **one**
853
+ * `atomicWrite`, finalize:
854
+ *
855
+ * 1. **Plan** (reads only): root `beforeOperation`, read, access; then {@link planRelationDelete} walks
856
+ * every cascade/set-null/restrict reference to its fixpoint, judges restrict and required set-null
857
+ * against the final state, and refuses a plan that cannot fit in one batch — before any `before*`
858
+ * hook of the graph and before any write.
859
+ * 2. **Prepare** (hooks, validation; no writes): root `beforeDelete`; each cascaded document's
860
+ * `beforeOperation` + `beforeDelete`; each set-null document through the same {@link prepareUpdate}
861
+ * `update()` uses. Dependents run with `overrideAccess: true` — a consequence of an authorized delete,
862
+ * like a database's own `ON DELETE CASCADE` (spec 058 §5) — but never skip validation or hooks.
863
+ * 3. **Commit**: set-null patches (versioned: spec 062's update + snapshot), cascaded deletes guarded by
864
+ * the `updated_at` they were planned with, the root delete, and finally one "no reference remains"
865
+ * `assertCount` per referring field — all in one batch, so a late failure or a reference created
866
+ * concurrently leaves every row unchanged.
867
+ * 4. **Finalize** (after commit): storage cleanup for deleted upload documents, then after-hooks —
868
+ * dependents first, root last.
869
+ *
870
+ * Before-hooks are not transactional: a side effect they performed outside the database survives a
871
+ * rollback of the batch (roadmap D01).
872
+ */
440
873
  export async function deleteDocument(ctx, args) {
441
874
  const collection = getCollectionOrThrow(ctx, args.collection);
875
+ assertNotAuthManaged(ctx, args.collection);
442
876
  const user = args.user ?? null;
443
877
  const overrideAccess = args.overrideAccess !== false;
444
878
  await runBeforeOperationHooks(collection, { operation: 'delete', user, overrideAccess });
@@ -453,37 +887,311 @@ export async function deleteDocument(ctx, args) {
453
887
  if (decision.where && !documentMatches(existing, decision.where)) {
454
888
  throw new AccessDeniedError();
455
889
  }
890
+ // 1. Plan — reads only.
891
+ const plan = await planRelationDelete(ctx, { collection, doc: existing });
892
+ const dependents = plan.deletes.slice(1);
893
+ // 2. Prepare — hooks and validation, root first; nothing is written yet.
456
894
  await runRejectableStage(() => runBeforeDeleteHooks(collection, { user, overrideAccess, id: args.id, doc: existing }), 'beforeDelete hook');
457
- // Check relation integrity constraints
458
- await checkDeleteRestrictions(ctx, collection, args.id);
459
- // Handle cascade and set-null before deleting
460
- await handleCascadeDelete(ctx, collection, args.id);
461
- await handleSetNullOnDelete(ctx, collection, args.id);
462
- // The database delete must succeed — and only then does the underlying storage object get
463
- // removed. Deleting the object first (or on a rejected/failed database delete) would orphan the
464
- // document from its file; deleting it only after confirms the document is really gone.
465
- await ctx.adapters.database.delete(args.collection, args.id);
466
- if (collection.upload === true) {
467
- const storageKey = resolveStorageKey(args.collection, existing);
468
- if (storageKey) {
469
- try {
470
- await ctx.adapters.storage.delete(storageKey);
471
- }
472
- catch (cleanupErr) {
473
- // The document is already gone; failing the whole operation over cleanup would be worse
474
- // than a best-effort delete that gets logged and left for manual follow-up.
475
- getLogger().error(`Failed to clean up storage object '${storageKey}' after document deletion`, cleanupErr);
476
- }
477
- }
895
+ for (const dependent of dependents) {
896
+ await prepareDependentDelete(dependent, user);
897
+ }
898
+ const updates = [];
899
+ // A set-null dependent's hooks may write other relation values; those are checked like any update's.
900
+ const targetGuards = [];
901
+ for (const planned of plan.setNulls) {
902
+ const prepared = await prepareSetNull(ctx, planned, user);
903
+ if (!prepared)
904
+ continue;
905
+ updates.push(prepared);
906
+ targetGuards.push(...(await relationTargetGuards(ctx, prepared.collection, prepared.data, prepared.existing)));
907
+ }
908
+ // Each deleted upload document's object gets a durable deletion intent in the same batch (spec 067).
909
+ const storageCleanups = plan.deletes.flatMap(({ collection: target, doc }) => {
910
+ const key = target.upload === true ? ownedStorageKey(doc) : null;
911
+ if (target.upload === true && key === null)
912
+ warnNoStorageKey(target, doc);
913
+ return key === null ? [] : [{ key, operation: deletionIntent(target.slug, key) }];
914
+ });
915
+ // 3. Commit — one batch, or the plain single delete when nothing else is involved.
916
+ let updatedRecords = [];
917
+ let intentIds = [];
918
+ if (dependents.length === 0 &&
919
+ updates.length === 0 &&
920
+ plan.assertions.length === 0 &&
921
+ targetGuards.length === 0 &&
922
+ storageCleanups.length === 0) {
923
+ await deleteRoot(ctx, collection, args.id, decision.where);
924
+ }
925
+ else {
926
+ ({ updatedRecords, intentIds } = await commitRelationDelete(ctx, { collection, id: args.id }, dependents, updates, [...targetGuards, ...plan.assertions], storageCleanups.map((cleanup) => cleanup.operation), decision.where));
927
+ }
928
+ // 4. Finalize — storage first (so a slow or failing hook cannot skip it), then after-hooks,
929
+ // dependents first and the root last, as before spec 064. A failed object delete keeps its intent.
930
+ for (const [index, { key }] of storageCleanups.entries()) {
931
+ const intentId = intentIds[index];
932
+ if (intentId !== undefined)
933
+ await finishDeletion(ctx, intentId, key);
934
+ }
935
+ for (const { collection: target, doc } of [...dependents].reverse()) {
936
+ const hookArgs = { user, overrideAccess: true, id: doc.id, doc };
937
+ await runAfterDeleteHooks(target, hookArgs);
938
+ await runAfterOperationHooks(target, {
939
+ operation: 'delete',
940
+ user,
941
+ overrideAccess: true,
942
+ result: doc
943
+ });
944
+ }
945
+ for (const [index, prepared] of updates.entries()) {
946
+ await finalizeUpdate(ctx, prepared, updatedRecords[index] ?? prepared.existing);
478
947
  }
479
948
  await runAfterDeleteHooks(collection, { user, overrideAccess, id: args.id, doc: existing });
949
+ // A trusted delete keeps returning the stored row; an access-checked one returns only what the caller
950
+ // may read of it (spec 068) — before, it returned the raw row, read-denied fields included.
951
+ const result = args.overrideAccess === false ? await writeResult(ctx, collection, existing, args) : existing;
480
952
  await runAfterOperationHooks(collection, {
481
953
  operation: 'delete',
482
954
  user,
483
955
  overrideAccess,
484
- result: existing
956
+ result
485
957
  });
486
- return existing;
958
+ return result;
959
+ }
960
+ /**
961
+ * The plain single-document delete. With a query-returning delete rule the row must still match it at
962
+ * the delete (spec 068): a row gone meanwhile is a `404`, one moved out of the caller's scope a `409`.
963
+ */
964
+ async function deleteRoot(ctx, collection, id, accessWhere) {
965
+ if (accessWhere === undefined) {
966
+ await ctx.adapters.database.delete(collection.slug, id);
967
+ return;
968
+ }
969
+ const result = await ctx.adapters.database.deleteIf(collection.slug, id, {
970
+ targetMatches: accessWhere
971
+ });
972
+ if (result.applied)
973
+ return;
974
+ if (!(await ctx.adapters.database.findById(collection.slug, id)))
975
+ throw notFound(collection.slug, id);
976
+ throw new ConcurrentModificationError(collection.slug, id, `Document '${id}' in '${collection.slug}' was changed by another request so that it no longer ` +
977
+ `matches the caller's delete access; nothing was deleted. Reload it and try again.`);
978
+ }
979
+ /** A cascaded document's before-phase: the same hooks its own delete runs (trusted, spec 058 §5). */
980
+ async function prepareDependentDelete(planned, user) {
981
+ const { collection, doc } = planned;
982
+ await runBeforeOperationHooks(collection, { operation: 'delete', user, overrideAccess: true });
983
+ await runRejectableStage(() => runBeforeDeleteHooks(collection, {
984
+ user,
985
+ overrideAccess: true,
986
+ id: doc.id,
987
+ doc
988
+ }), 'beforeDelete hook');
989
+ }
990
+ /**
991
+ * A set-null dependent through the normal update preparation, with the patch computed from the document
992
+ * as read there (spec 062's read order for a versioned dependent). `null` when there is nothing left to
993
+ * clear — the document no longer references the deleted ids, or no longer exists; the batch's final
994
+ * assertions still prove no reference survives.
995
+ */
996
+ async function prepareSetNull(ctx, planned, user) {
997
+ try {
998
+ return await prepareUpdate(ctx, {
999
+ collection: planned.collection.slug,
1000
+ id: planned.id,
1001
+ data: {},
1002
+ overrideAccess: true,
1003
+ ...(user !== null && { user })
1004
+ }, { patch: (doc) => setNullPatch(planned, doc) });
1005
+ }
1006
+ catch (err) {
1007
+ if (err instanceof NotFoundError)
1008
+ return null;
1009
+ throw err;
1010
+ }
1011
+ }
1012
+ /**
1013
+ * The single batch of a relation delete. Order: set-null patches, cascaded deletes, the root delete,
1014
+ * then the reference assertions — which therefore see the final state, including this batch's own
1015
+ * writes. Any failed condition (a dependent changed since it was planned, a reference created
1016
+ * concurrently, a dependent's snapshot losing its version race) rolls the whole batch back and is a
1017
+ * `409` about the root; nothing was written. Returns the committed row of each set-null update, in order.
1018
+ */
1019
+ async function commitRelationDelete(ctx, root, dependents, updates, assertions, intents = [], rootWhere) {
1020
+ const operations = [];
1021
+ const updateAt = [];
1022
+ for (const prepared of updates) {
1023
+ updateAt.push(operations.length);
1024
+ if (prepared.versioned) {
1025
+ atomicDatabase(ctx, prepared.collection);
1026
+ operations.push(...versionedUpdateOperations(prepared));
1027
+ }
1028
+ else {
1029
+ operations.push({
1030
+ type: 'updateIf',
1031
+ collection: prepared.collection.slug,
1032
+ id: prepared.id,
1033
+ data: prepared.data,
1034
+ condition: unchangedSince(prepared.existing),
1035
+ requireApplied: true
1036
+ });
1037
+ }
1038
+ }
1039
+ for (const { collection, doc } of dependents) {
1040
+ operations.push({
1041
+ type: 'deleteIf',
1042
+ collection: collection.slug,
1043
+ id: doc.id,
1044
+ condition: unchangedSince(doc),
1045
+ requireApplied: true
1046
+ });
1047
+ }
1048
+ // The caller's delete-access query must still hold when the batch commits (spec 068).
1049
+ operations.push(rootWhere === undefined
1050
+ ? { type: 'delete', collection: root.collection.slug, id: root.id }
1051
+ : {
1052
+ type: 'deleteIf',
1053
+ collection: root.collection.slug,
1054
+ id: root.id,
1055
+ condition: { targetMatches: rootWhere },
1056
+ requireApplied: true
1057
+ });
1058
+ const intentsAt = operations.length;
1059
+ operations.push(...intents);
1060
+ operations.push(...assertions);
1061
+ // Hook-written relation values of set-null dependents can add target checks after planning counted
1062
+ // the batch; still refuse rather than chunk (spec 064 §5). Before-hooks have run by now; nothing is written.
1063
+ if (operations.length > ATOMIC_WRITE_MAX_OPERATIONS) {
1064
+ throw new InvalidInputError(`Cannot delete document '${root.id}' from '${root.collection.slug}': its dependents' hooks added ` +
1065
+ `relation checks that take it past ${ATOMIC_WRITE_MAX_OPERATIONS} database operations, the most ` +
1066
+ `ForgeCMS commits atomically in one operation. Nothing was changed.`);
1067
+ }
1068
+ let results;
1069
+ try {
1070
+ results = await ctx.adapters.database.atomicWrite(operations);
1071
+ }
1072
+ catch (err) {
1073
+ const versionRace = updates.some((prepared) => prepared.versioned && isVersionIdentityConflict(err, prepared.collection));
1074
+ if (versionRace || isAtomicWriteConditionError(err)) {
1075
+ throw new ConcurrentModificationError(root.collection.slug, root.id, `Deleting document '${root.id}' from '${root.collection.slug}' conflicted with a concurrent ` +
1076
+ `change to it or to a document that references it; nothing was deleted or changed. Reload and ` +
1077
+ `try again.`);
1078
+ }
1079
+ if (isDbUniqueConstraintError(err))
1080
+ throw new UniqueConstraintError(err.collection, err.fields);
1081
+ throw err;
1082
+ }
1083
+ const updatedRecords = updateAt.map((at) => {
1084
+ const result = results[at];
1085
+ if (result && (result.type === 'update' || (result.type === 'updateIf' && result.applied))) {
1086
+ return result.record;
1087
+ }
1088
+ throw new Error('atomicWrite returned no record for a set-null update');
1089
+ });
1090
+ const intentIds = intents.map((_, offset) => {
1091
+ const result = results[intentsAt + offset];
1092
+ if (result?.type !== 'create')
1093
+ throw new Error('atomicWrite returned no storage intent');
1094
+ return result.record.id;
1095
+ });
1096
+ return { updatedRecords, intentIds };
1097
+ }
1098
+ /**
1099
+ * A non-persistent simulation of a permitted create/update — spec 058 §3. Before this fix, preview
1100
+ * read the raw adapter row and merged caller-supplied `data` with **no** access enforcement at all
1101
+ * (no collection/row/field-write check, no draft-visibility check, no field-read projection): any
1102
+ * caller who could reach the endpoint could preview (and see every hidden field of) any document,
1103
+ * and could smuggle a forbidden field into the merged output because it was never persisted.
1104
+ *
1105
+ * Existing-document preview now requires the same **update** access an actual `update()` call would
1106
+ * (row-level policy included) plus the same draft-visibility a normal single-document read applies —
1107
+ * stricter than raw `update()` (which has no draft gate), because preview hands the full merged
1108
+ * document back to the caller to render, unlike a blind write. New-document preview requires **create**
1109
+ * access. Both require field-write access on every field in `data` when untrusted
1110
+ * (`overrideAccess: false`), and project the returned document through the same field-read rules a
1111
+ * normal read would (hidden fields never appear in the output). Depth-1 population forwards the
1112
+ * caller's `user`/`overrideAccess`, so a populated target obeys the §4 population-visibility fix
1113
+ * instead of always resolving as a trusted call.
1114
+ *
1115
+ * Zero adapter writes, zero version creation, zero committed-mutation hooks — unchanged from before
1116
+ * this fix; this function never called `create()`/`update()`/`createVersion()`. Full
1117
+ * `validateCollection()` is deliberately not run: preview must be able to render an intentionally
1118
+ * incomplete draft, not just a document that would actually pass validation.
1119
+ */
1120
+ export async function preview(ctx, args) {
1121
+ const collection = getCollectionOrThrow(ctx, args.collection);
1122
+ const user = args.user ?? null;
1123
+ let existing = null;
1124
+ let previewData;
1125
+ if (args.id) {
1126
+ existing = await ctx.adapters.database.findById(args.collection, args.id);
1127
+ if (!existing)
1128
+ throw notFound(args.collection, args.id);
1129
+ const decision = await checkAccess(collection, 'update', {
1130
+ user,
1131
+ ...(args.overrideAccess !== undefined && { overrideAccess: args.overrideAccess }),
1132
+ id: args.id,
1133
+ data: args.data,
1134
+ doc: existing
1135
+ });
1136
+ if (decision.where && !documentMatches(existing, decision.where)) {
1137
+ throw notFound(args.collection, args.id);
1138
+ }
1139
+ // Draft visibility: a normal single-document read hides an inaccessible draft from an anonymous
1140
+ // caller behind a 404 (never confirming the document exists) — preview must not be a back door
1141
+ // around that, since it returns the full document body for the caller to render.
1142
+ const draftStatus = statusConstraint(collection, undefined, user, args.overrideAccess !== false, 'all');
1143
+ if (draftStatus && !documentMatches(existing, draftStatus)) {
1144
+ throw notFound(args.collection, args.id);
1145
+ }
1146
+ // Preview returns the stored document merged with the changes, so the caller must also be able to
1147
+ // *read* it, exactly as `findByID` would allow (spec 068 review): update access alone is not a read.
1148
+ if (args.overrideAccess === false && !(await canRead(collection, existing, user))) {
1149
+ throw notFound(args.collection, args.id);
1150
+ }
1151
+ // Preview models a permitted update, so it takes the same content input (spec 063 §7).
1152
+ const input = screenUpdateInput(args.data, existing);
1153
+ if (args.overrideAccess === false) {
1154
+ try {
1155
+ await assertWritableFields(input, collection, user, 'update');
1156
+ }
1157
+ catch (err) {
1158
+ if (err instanceof FieldAccessError)
1159
+ throw new AccessDeniedError(err.message);
1160
+ throw err;
1161
+ }
1162
+ }
1163
+ previewData = { ...existing, ...input };
1164
+ }
1165
+ else {
1166
+ await checkAccess(collection, 'create', {
1167
+ user,
1168
+ ...(args.overrideAccess !== undefined && { overrideAccess: args.overrideAccess }),
1169
+ data: args.data
1170
+ });
1171
+ const { content, id } = screenCreateInput(args.data, args.overrideAccess !== false);
1172
+ if (args.overrideAccess === false) {
1173
+ try {
1174
+ await assertWritableFields(content, collection, user, 'create');
1175
+ }
1176
+ catch (err) {
1177
+ if (err instanceof FieldAccessError)
1178
+ throw new AccessDeniedError(err.message);
1179
+ throw err;
1180
+ }
1181
+ }
1182
+ previewData = id !== undefined ? { ...content, id } : content;
1183
+ }
1184
+ previewData = applyAutoSlugs(collection, applyFieldDefaults(collection, previewData), existing ?? undefined);
1185
+ if (args.depth && args.depth > 0) {
1186
+ previewData = await populateRecord(previewData, collection, ctx, {
1187
+ user,
1188
+ ...(args.overrideAccess !== undefined && { overrideAccess: args.overrideAccess })
1189
+ });
1190
+ }
1191
+ if (args.overrideAccess === false) {
1192
+ previewData = await filterReadableFields(previewData, collection, user);
1193
+ }
1194
+ return previewData;
487
1195
  }
488
1196
  export { populateRecord, populateRecords };
489
1197
  //# sourceMappingURL=operations.js.map