@forge-cms/runtime 0.5.0 → 0.7.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 (71) hide show
  1. package/dist/auth-handlers.d.ts +43 -1
  2. package/dist/auth-handlers.d.ts.map +1 -1
  3. package/dist/auth-handlers.js +64 -18
  4. package/dist/auth-handlers.js.map +1 -1
  5. package/dist/auth-managed.d.ts +19 -0
  6. package/dist/auth-managed.d.ts.map +1 -0
  7. package/dist/auth-managed.js +24 -0
  8. package/dist/auth-managed.js.map +1 -0
  9. package/dist/body.d.ts +28 -0
  10. package/dist/body.d.ts.map +1 -0
  11. package/dist/body.js +81 -0
  12. package/dist/body.js.map +1 -0
  13. package/dist/concurrency.d.ts +8 -0
  14. package/dist/concurrency.d.ts.map +1 -0
  15. package/dist/concurrency.js +15 -0
  16. package/dist/concurrency.js.map +1 -0
  17. package/dist/context.d.ts +5 -0
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/errors.d.ts +46 -1
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +61 -0
  22. package/dist/errors.js.map +1 -1
  23. package/dist/files.d.ts +15 -1
  24. package/dist/files.d.ts.map +1 -1
  25. package/dist/files.js +55 -14
  26. package/dist/files.js.map +1 -1
  27. package/dist/globals.d.ts +21 -3
  28. package/dist/globals.d.ts.map +1 -1
  29. package/dist/globals.js +216 -28
  30. package/dist/globals.js.map +1 -1
  31. package/dist/handlers.d.ts +3 -2
  32. package/dist/handlers.d.ts.map +1 -1
  33. package/dist/handlers.js +51 -49
  34. package/dist/handlers.js.map +1 -1
  35. package/dist/index.d.ts +5 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +6 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/localization.d.ts +17 -0
  40. package/dist/localization.d.ts.map +1 -1
  41. package/dist/localization.js +56 -0
  42. package/dist/localization.js.map +1 -1
  43. package/dist/operations.d.ts +51 -12
  44. package/dist/operations.d.ts.map +1 -1
  45. package/dist/operations.js +728 -125
  46. package/dist/operations.js.map +1 -1
  47. package/dist/relation-integrity.d.ts +31 -13
  48. package/dist/relation-integrity.d.ts.map +1 -1
  49. package/dist/relation-integrity.js +62 -6
  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 +21 -0
  56. package/dist/runtime.d.ts.map +1 -1
  57. package/dist/runtime.js +112 -20
  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/versions.d.ts +73 -1
  68. package/dist/versions.d.ts.map +1 -1
  69. package/dist/versions.js +204 -17
  70. package/dist/versions.js.map +1 -1
  71. package/package.json +9 -7
@@ -1,16 +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';
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
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';
7
8
  import { checkAccess, statusConstraint } from './read-policy.js';
8
9
  import { runAfterChangeHooks, runAfterDeleteHooks, runAfterOperationHooks, runAfterReadHooks, runBeforeChangeHooks, runBeforeDeleteHooks, runBeforeOperationHooks, runBeforeReadHooks, runBeforeValidateHooks, runFieldHooks } from './hooks.js';
9
10
  import { assertWritableFields, filterReadableFields, FieldAccessError } from './field-access.js';
10
11
  import { populateRecord, populateRecords } from './populate.js';
11
- import { createVersion, getVersion, versionsEnabled } from './versions.js';
12
- import { isLocalizedCollection, storeLocalizedDocument, resolveLocalizedDocument } from './localization.js';
13
- 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';
14
18
  function getCollectionOrThrow(ctx, slug) {
15
19
  const collection = ctx.getCollection(slug);
16
20
  if (!collection)
@@ -36,6 +40,243 @@ async function runWrite(collection, op) {
36
40
  throw err;
37
41
  }
38
42
  }
43
+ /**
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.
47
+ */
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.`);
64
+ }
65
+ /**
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.
71
+ *
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`.
75
+ */
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);
279
+ }
39
280
  /**
40
281
  * Runs a stage that may reject the write. A hook throwing a plain `Error` is a rejection of the
41
282
  * caller's payload (400), not a server fault — preserving the spec-013 contract that a throwing
@@ -202,14 +443,71 @@ export async function count(ctx, args) {
202
443
  return result;
203
444
  }
204
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) {
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) {
205
499
  const collection = getCollectionOrThrow(ctx, args.collection);
500
+ assertNotAuthManaged(ctx, args.collection);
206
501
  const user = args.user ?? null;
207
502
  const overrideAccess = args.overrideAccess !== false;
208
503
  await runBeforeOperationHooks(collection, { operation: 'create', user, overrideAccess });
209
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);
210
508
  if (args.overrideAccess === false) {
211
509
  try {
212
- await assertWritableFields(args.data, collection, user, 'create');
510
+ await assertWritableFields(content, collection, user, 'create');
213
511
  }
214
512
  catch (err) {
215
513
  if (err instanceof FieldAccessError)
@@ -219,7 +517,7 @@ export async function create(ctx, args) {
219
517
  }
220
518
  // Defaults and auto-slugs are resolved before any hook runs, so a hook still gets the last word
221
519
  // and validation only ever sees the final value.
222
- const seeded = applyAutoSlugs(collection, applyFieldDefaults(collection, args.data));
520
+ const seeded = applyAutoSlugs(collection, applyFieldDefaults(collection, content));
223
521
  // Process localized fields if the collection has locales configured
224
522
  const processedData = isLocalizedCollection(collection) && args.locale
225
523
  ? storeLocalizedDocument(seeded, collection, args.locale)
@@ -235,6 +533,7 @@ export async function create(ctx, args) {
235
533
  user,
236
534
  overrideAccess
237
535
  }), 'beforeValidate hook');
536
+ data = screenHookOutput(data, {}, 'beforeValidate', args.collection);
238
537
  assertDraftStatus(collection, data);
239
538
  if (collection.drafts === true && data._status === undefined) {
240
539
  data = { ...data, _status: 'draft' };
@@ -253,16 +552,19 @@ export async function create(ctx, args) {
253
552
  user,
254
553
  overrideAccess
255
554
  }), 'beforeChange hook');
256
- const record = await runWrite(args.collection, () => ctx.adapters.database.create(args.collection, data));
257
- // Create initial version if versions are enabled
258
- if (versionsEnabled(collection)) {
259
- await createVersion(ctx, {
260
- collection: args.collection,
261
- documentId: record.id,
262
- data,
263
- user
264
- });
265
- }
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);
266
568
  await runAfterChangeHooks(collection, {
267
569
  operation: 'create',
268
570
  data,
@@ -271,31 +573,69 @@ export async function create(ctx, args) {
271
573
  user,
272
574
  overrideAccess
273
575
  });
274
- const [doc] = await prepareForRead(ctx, collection, [record], args);
275
- const result = doc ?? record;
576
+ const result = await writeResult(ctx, collection, record, args);
276
577
  await runAfterOperationHooks(collection, { operation: 'create', user, overrideAccess, result });
277
578
  return result;
278
579
  }
279
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 = {}) {
280
596
  const collection = getCollectionOrThrow(ctx, args.collection);
597
+ assertNotAuthManaged(ctx, args.collection);
281
598
  const user = args.user ?? null;
282
599
  const overrideAccess = args.overrideAccess !== false;
283
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;
284
608
  const existing = await ctx.adapters.database.findById(args.collection, args.id);
285
609
  if (!existing)
286
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
+ }
287
624
  const decision = await checkAccess(collection, 'update', {
288
625
  ...args,
289
626
  id: args.id,
290
- data: args.data,
627
+ data: requested,
291
628
  doc: existing
292
629
  });
293
630
  if (decision.where && !documentMatches(existing, decision.where)) {
294
631
  throw new AccessDeniedError();
295
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);
296
636
  if (args.overrideAccess === false) {
297
637
  try {
298
- await assertWritableFields(args.data, collection, user, 'update');
638
+ await assertWritableFields(input, collection, user, 'update');
299
639
  }
300
640
  catch (err) {
301
641
  if (err instanceof FieldAccessError)
@@ -305,8 +645,8 @@ export async function update(ctx, args) {
305
645
  }
306
646
  // Process localized fields if the collection has locales configured
307
647
  const processedData = isLocalizedCollection(collection) && args.locale
308
- ? storeLocalizedDocument(args.data, collection, args.locale, existing)
309
- : args.data;
648
+ ? storeLocalizedDocument(input, collection, args.locale, existing)
649
+ : input;
310
650
  let data = await runRejectableStage(async () => runBeforeValidateHooks(collection, {
311
651
  operation: 'update',
312
652
  data: await runFieldHooks(collection, 'beforeValidate', {
@@ -320,6 +660,7 @@ export async function update(ctx, args) {
320
660
  user,
321
661
  overrideAccess
322
662
  }), 'beforeValidate hook');
663
+ data = screenHookOutput(data, existing, 'beforeValidate', args.collection);
323
664
  assertDraftStatus(collection, data);
324
665
  // Validate the merged document so required fields already stored do not fail a partial update,
325
666
  // then report only the errors the caller can actually act on: fields they are touching, or fields
@@ -347,17 +688,24 @@ export async function update(ctx, args) {
347
688
  user,
348
689
  overrideAccess
349
690
  }), 'beforeChange hook');
350
- const record = await runWrite(args.collection, () => ctx.adapters.database.update(args.collection, args.id, data));
351
- // Create a version snapshot if versions are enabled
352
- if (versionsEnabled(collection)) {
353
- await createVersion(ctx, {
354
- collection: args.collection,
355
- documentId: args.id,
356
- data,
357
- user,
358
- ...(args.versionLabel !== undefined && { label: args.versionLabel })
359
- });
360
- }
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;
361
709
  await runAfterChangeHooks(collection, {
362
710
  operation: 'update',
363
711
  data,
@@ -367,81 +715,164 @@ export async function update(ctx, args) {
367
715
  user,
368
716
  overrideAccess
369
717
  });
370
- const [doc] = await prepareForRead(ctx, collection, [record], args);
371
- const result = doc ?? record;
718
+ const result = await writeResult(ctx, collection, record, prepared.args);
372
719
  await runAfterOperationHooks(collection, { operation: 'update', user, overrideAccess, result });
373
720
  return result;
374
721
  }
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
+ }
375
797
  /**
376
- * Restores a document to a specific historical version — spec 058 §2. Unlike the pre-058
377
- * implementation (which wrote through `ctx.adapters.database.update()` directly, bypassing access,
378
- * validation, and hooks), this fetches the raw version snapshot and then calls this module's own
379
- * `update()`, so a restore gets exactly the same update-access/row-policy/field-write/validation/hook
380
- * pipeline a normal update gets, and creates exactly one labeled version (via `versionLabel`) instead
381
- * of a second, bespoke version write.
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.
382
805
  *
383
- * The version snapshot itself is always fetched unfiltered (`overrideAccess: true` on the internal
384
- * `getVersion` call) — restore's authorization gate is `update()`'s own update-access check on the
385
- * *current* document, exactly like calling `update()` directly would behave; there is no separate
386
- * "can this caller read this document's history" gate for restore (see spec 058 §2 for the reasoning).
387
- * A forbidden or invalid restore throws before `update()` ever reaches the adapter write, so both the
388
- * current document and its history are left unchanged.
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.
389
809
  */
390
810
  export async function restoreVersion(ctx, args) {
391
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);
392
814
  if (!versionsEnabled(collection)) {
393
815
  throw new Error(`Collection '${args.collection}' does not have versions enabled`);
394
816
  }
395
- const version = await getVersion(ctx, {
817
+ const restorable = await readVersionForRestore(ctx, collection, args.versionId);
818
+ return updateDocument(ctx, {
396
819
  collection: args.collection,
397
- versionId: args.versionId,
398
- overrideAccess: true
399
- });
400
- return update(ctx, {
401
- collection: args.collection,
402
- id: version.documentId,
403
- data: version.data,
820
+ id: restorable.version.documentId,
821
+ data: {},
404
822
  ...(args.user !== undefined && { user: args.user }),
405
823
  ...(args.overrideAccess !== undefined && { overrideAccess: args.overrideAccess }),
406
- versionLabel: `Restored from version ${version.versionNumber}`
407
- });
824
+ versionLabel: `Restored from version ${restorable.version.versionNumber}`
825
+ }, restorable);
408
826
  }
409
- const MEDIA_URL_PREFIX = '/api/media/';
410
827
  /**
411
- * Resolves the storage key a stored upload's underlying object lives under: the `_storageKey` every
412
- * upload-created document carries, or (for an older/manually-created record without one) a fallback
413
- * parsed from its `url`, matching the default `/api/media/<collection>/<key>` shape `handleFile` and
414
- * every `StorageAdapter`'s default `getPublicUrl` use.
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.
415
832
  */
416
- function resolveStorageKey(collectionSlug, doc) {
833
+ function ownedStorageKey(doc) {
417
834
  const storageKey = doc._storageKey;
418
- if (typeof storageKey === 'string' && storageKey.length > 0)
419
- return storageKey;
420
- const url = doc.url;
421
- if (typeof url !== 'string')
422
- return null;
423
- const prefix = `${MEDIA_URL_PREFIX}${collectionSlug}/`;
424
- const idx = url.indexOf(prefix);
425
- return idx === -1 ? null : url.slice(idx + MEDIA_URL_PREFIX.length);
835
+ return typeof storageKey === 'string' && storageKey.length > 0 ? storageKey : null;
426
836
  }
427
- export async function deleteDocument(ctx, args) {
428
- return deleteDocumentInternal(ctx, args, new Set());
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)`);
429
850
  }
430
851
  /**
431
- * The real implementation behind {@link deleteDocument}, plus every cascade-triggered dependent
432
- * delete (spec 058 §5) — cascade's `RelationMutator.deleteDocument` calls straight back into this
433
- * function with the same `visited` set, so a multi-level cascade chain runs the full pipeline (access,
434
- * hooks, relation integrity) at every level. Cycle/diamond protection lives entirely on the *caller*
435
- * side (`handleCascadeDelete`/`handleSetNullOnDelete` check `visited` before ever invoking the
436
- * mutator — see that module) — this function marks its own key visited (below) precisely so those
437
- * caller-side checks can see it, but does not re-check its own key at entry: by the time this function
438
- * is reached through the mutator, the caller has already decided this key needs processing exactly
439
- * once, and re-checking here would treat "the caller just claimed this key" as "already done" and skip
440
- * the actual deletion — see this spec's implementation notes for the regression this caused.
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).
441
872
  */
442
- async function deleteDocumentInternal(ctx, args, visited) {
443
- const key = `${args.collection}:${args.id}`;
873
+ export async function deleteDocument(ctx, args) {
444
874
  const collection = getCollectionOrThrow(ctx, args.collection);
875
+ assertNotAuthManaged(ctx, args.collection);
445
876
  const user = args.user ?? null;
446
877
  const overrideAccess = args.overrideAccess !== false;
447
878
  await runBeforeOperationHooks(collection, { operation: 'delete', user, overrideAccess });
@@ -456,49 +887,213 @@ async function deleteDocumentInternal(ctx, args, visited) {
456
887
  if (decision.where && !documentMatches(existing, decision.where)) {
457
888
  throw new AccessDeniedError();
458
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.
459
894
  await runRejectableStage(() => runBeforeDeleteHooks(collection, { user, overrideAccess, id: args.id, doc: existing }), 'beforeDelete hook');
460
- visited.add(key);
461
- // Check relation integrity constraints (restrict, and required-field-on-set-null) before any
462
- // mutation happens.
463
- await checkDeleteRestrictions(ctx, collection, args.id);
464
- // Handle cascade and set-null before deleting — routed through this module's own
465
- // `deleteDocument`/`update` via a `RelationMutator`, so dependent mutations get the full pipeline
466
- // (access, field-write checks, validation, hooks, version snapshots) instead of a raw adapter write.
467
- // Both run with `overrideAccess: true`: a cascade/set-null is a consequence of an already-authorized
468
- // delete, the same way a database's own `ON DELETE CASCADE` doesn't re-run application ACL per
469
- // cascaded row — a deliberate, documented choice, not an oversight (spec 058 §5).
470
- const mutator = {
471
- deleteDocument: (a) => deleteDocumentInternal(ctx, { ...a, overrideAccess: true }, visited),
472
- update: (a) => update(ctx, { ...a, overrideAccess: true })
473
- };
474
- const integrityOptions = { mutator, visited, ...(user !== null && { user }) };
475
- await handleCascadeDelete(ctx, collection, args.id, integrityOptions);
476
- await handleSetNullOnDelete(ctx, collection, args.id, integrityOptions);
477
- // The database delete must succeed — and only then does the underlying storage object get
478
- // removed. Deleting the object first (or on a rejected/failed database delete) would orphan the
479
- // document from its file; deleting it only after confirms the document is really gone.
480
- await ctx.adapters.database.delete(args.collection, args.id);
481
- if (collection.upload === true) {
482
- const storageKey = resolveStorageKey(args.collection, existing);
483
- if (storageKey) {
484
- try {
485
- await ctx.adapters.storage.delete(storageKey);
486
- }
487
- catch (cleanupErr) {
488
- // The document is already gone; failing the whole operation over cleanup would be worse
489
- // than a best-effort delete that gets logged and left for manual follow-up.
490
- getLogger().error(`Failed to clean up storage object '${storageKey}' after document deletion`, cleanupErr);
491
- }
492
- }
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);
493
947
  }
494
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;
495
952
  await runAfterOperationHooks(collection, {
496
953
  operation: 'delete',
497
954
  user,
498
955
  overrideAccess,
499
- result: existing
956
+ result
957
+ });
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
500
971
  });
501
- return existing;
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 };
502
1097
  }
503
1098
  /**
504
1099
  * A non-persistent simulation of a permitted create/update — spec 058 §3. Before this fix, preview
@@ -548,9 +1143,16 @@ export async function preview(ctx, args) {
548
1143
  if (draftStatus && !documentMatches(existing, draftStatus)) {
549
1144
  throw notFound(args.collection, args.id);
550
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);
551
1153
  if (args.overrideAccess === false) {
552
1154
  try {
553
- await assertWritableFields(args.data, collection, user, 'update');
1155
+ await assertWritableFields(input, collection, user, 'update');
554
1156
  }
555
1157
  catch (err) {
556
1158
  if (err instanceof FieldAccessError)
@@ -558,7 +1160,7 @@ export async function preview(ctx, args) {
558
1160
  throw err;
559
1161
  }
560
1162
  }
561
- previewData = { ...existing, ...args.data };
1163
+ previewData = { ...existing, ...input };
562
1164
  }
563
1165
  else {
564
1166
  await checkAccess(collection, 'create', {
@@ -566,9 +1168,10 @@ export async function preview(ctx, args) {
566
1168
  ...(args.overrideAccess !== undefined && { overrideAccess: args.overrideAccess }),
567
1169
  data: args.data
568
1170
  });
1171
+ const { content, id } = screenCreateInput(args.data, args.overrideAccess !== false);
569
1172
  if (args.overrideAccess === false) {
570
1173
  try {
571
- await assertWritableFields(args.data, collection, user, 'create');
1174
+ await assertWritableFields(content, collection, user, 'create');
572
1175
  }
573
1176
  catch (err) {
574
1177
  if (err instanceof FieldAccessError)
@@ -576,7 +1179,7 @@ export async function preview(ctx, args) {
576
1179
  throw err;
577
1180
  }
578
1181
  }
579
- previewData = args.data;
1182
+ previewData = id !== undefined ? { ...content, id } : content;
580
1183
  }
581
1184
  previewData = applyAutoSlugs(collection, applyFieldDefaults(collection, previewData), existing ?? undefined);
582
1185
  if (args.depth && args.depth > 0) {