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