@forge-cms/runtime 0.5.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.
- package/dist/auth-managed.d.ts +19 -0
- package/dist/auth-managed.d.ts.map +1 -0
- package/dist/auth-managed.js +24 -0
- package/dist/auth-managed.js.map +1 -0
- package/dist/concurrency.d.ts +8 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +15 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/context.d.ts +5 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/errors.d.ts +29 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +38 -0
- package/dist/errors.js.map +1 -1
- package/dist/files.d.ts +15 -1
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +55 -14
- package/dist/files.js.map +1 -1
- package/dist/globals.d.ts +21 -3
- package/dist/globals.d.ts.map +1 -1
- package/dist/globals.js +216 -28
- package/dist/globals.js.map +1 -1
- package/dist/handlers.d.ts +3 -2
- package/dist/handlers.d.ts.map +1 -1
- package/dist/handlers.js +36 -47
- package/dist/handlers.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/localization.d.ts +17 -0
- package/dist/localization.d.ts.map +1 -1
- package/dist/localization.js +56 -0
- package/dist/localization.js.map +1 -1
- package/dist/operations.d.ts +51 -12
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +728 -125
- package/dist/operations.js.map +1 -1
- package/dist/relation-integrity.d.ts +31 -13
- package/dist/relation-integrity.d.ts.map +1 -1
- package/dist/relation-integrity.js +62 -6
- package/dist/relation-integrity.js.map +1 -1
- package/dist/relation-lifecycle.d.ts +95 -0
- package/dist/relation-lifecycle.d.ts.map +1 -0
- package/dist/relation-lifecycle.js +424 -0
- package/dist/relation-lifecycle.js.map +1 -0
- package/dist/runtime.d.ts +21 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +112 -20
- package/dist/runtime.js.map +1 -1
- package/dist/storage-intents.d.ts +96 -0
- package/dist/storage-intents.d.ts.map +1 -0
- package/dist/storage-intents.js +217 -0
- package/dist/storage-intents.js.map +1 -0
- package/dist/system-fields.d.ts +27 -0
- package/dist/system-fields.d.ts.map +1 -0
- package/dist/system-fields.js +74 -0
- package/dist/system-fields.js.map +1 -0
- package/dist/versions.d.ts +73 -1
- package/dist/versions.d.ts.map +1 -1
- package/dist/versions.js +204 -17
- package/dist/versions.js.map +1 -1
- package/package.json +9 -7
package/dist/operations.js
CHANGED
|
@@ -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 {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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(
|
|
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,
|
|
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
|
-
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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(
|
|
309
|
-
:
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
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.
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
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
|
|
817
|
+
const restorable = await readVersionForRestore(ctx, collection, args.versionId);
|
|
818
|
+
return updateDocument(ctx, {
|
|
396
819
|
collection: args.collection,
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
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
|
|
833
|
+
function ownedStorageKey(doc) {
|
|
417
834
|
const storageKey = doc._storageKey;
|
|
418
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
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
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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, ...
|
|
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(
|
|
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 =
|
|
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) {
|