@kubun/mutation 0.16.0 → 0.17.1
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/lib/apply.d.ts +139 -4
- package/lib/apply.js +541 -46
- package/lib/create.d.ts +12 -2
- package/lib/create.js +29 -2
- package/lib/errors.d.ts +103 -0
- package/lib/errors.js +165 -1
- package/lib/index.d.ts +6 -5
- package/lib/index.js +4 -3
- package/lib/operations.d.ts +15 -2
- package/lib/operations.js +21 -4
- package/lib/state-digest.d.ts +16 -0
- package/lib/state-digest.js +21 -0
- package/package.json +6 -7
package/lib/apply.js
CHANGED
|
@@ -4,7 +4,8 @@ import { isValidAccessRule, RESERVED_DOCUMENT_FIELDS } from '@kubun/protocol';
|
|
|
4
4
|
import { fromB64 } from '@sozai/codec';
|
|
5
5
|
import { applyPatches } from '@sozai/patch';
|
|
6
6
|
import { asType, createValidator } from '@sozai/schema';
|
|
7
|
-
import { DocumentIDMismatchError, HLCDriftError, MutationSchemaValidationError, OwnerChangeError, UnsupportedMutationTypeError, WriteAccessDeniedError } from './errors.js';
|
|
7
|
+
import { DocumentIDMismatchError, HLCDriftError, IncompleteProviderGroupError, InvalidFetchTokenError, MergePolicyRemoveRequiresTombstoneError, MissingFetchTokenError, MutationSchemaValidationError, OwnerChangeError, ProviderGroupChangeRejectedError, TombstoneRequiresMergePolicyError, UnsupportedMutationTypeError, WriteAccessDeniedError } from './errors.js';
|
|
8
|
+
import { DELETED_STATE_DIGEST, stateDigest } from './state-digest.js';
|
|
8
9
|
/**
|
|
9
10
|
* Reserved `field_hlcs` key holding the greatest delete HLC observed for the
|
|
10
11
|
* document (the tombstone timestamp `t`).
|
|
@@ -13,6 +14,20 @@ import { DocumentIDMismatchError, HLCDriftError, MutationSchemaValidationError,
|
|
|
13
14
|
* Reserved `field_hlcs` key holding the greatest set HLC observed for the
|
|
14
15
|
* document (the set timestamp `s`).
|
|
15
16
|
*/ const SET_KEY = '$set';
|
|
17
|
+
/**
|
|
18
|
+
* Reserved key holding a merge-policy model's winning-live fetch-order
|
|
19
|
+
* register for its provider group: `{ hlc, digest }`, the greatest
|
|
20
|
+
* `sourceFetchHLC` observed for a live candidate, paired with that
|
|
21
|
+
* candidate's state digest. The bare `hlc` also lands in `field_hlcs` under
|
|
22
|
+
* this same key (mirroring `SET_KEY`) for the total-order comparison;
|
|
23
|
+
* `field_values` carries the full cell so `winnerStateDigestOf` can read the
|
|
24
|
+
* digest without a second store round trip.
|
|
25
|
+
*/ export const SET_FETCH_KEY = '$setFetch';
|
|
26
|
+
/**
|
|
27
|
+
* Reserved key holding a merge-policy model's winning-delete fetch-order
|
|
28
|
+
* register: `{ hlc }`, the greatest deleting `sourceFetchHLC` observed. Same
|
|
29
|
+
* `field_hlcs`/`field_values` split as `SET_FETCH_KEY`.
|
|
30
|
+
*/ export const DELETE_FETCH_KEY = '/fetch';
|
|
16
31
|
export function getDocumentValidator(ctx, id) {
|
|
17
32
|
if (ctx.validators[id] == null) {
|
|
18
33
|
ctx.validators[id] = ctx.store.getDocumentModel(id).then((model)=>createValidator({
|
|
@@ -33,14 +48,22 @@ export function getDocumentValidator(ctx, id) {
|
|
|
33
48
|
* other. The past direction is deliberately unbounded — an offline-first write
|
|
34
49
|
* lands whenever it lands.
|
|
35
50
|
*/ export const DEFAULT_MAX_DRIFT_MS = 3_600_000;
|
|
36
|
-
function
|
|
37
|
-
const ts = HLC.
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
function assertTokenDrift(token, maxDriftMS) {
|
|
52
|
+
const ts = HLC.tryParse(token);
|
|
53
|
+
if (ts == null) {
|
|
54
|
+
throw new InvalidFetchTokenError(token);
|
|
55
|
+
}
|
|
56
|
+
const drift = ts.wallTime - Date.now();
|
|
40
57
|
if (drift > maxDriftMS) {
|
|
41
58
|
throw new HLCDriftError(drift, maxDriftMS);
|
|
42
59
|
}
|
|
43
60
|
}
|
|
61
|
+
function validateHLCDrift(hlcString, maxDriftMS) {
|
|
62
|
+
assertTokenDrift(hlcString, maxDriftMS);
|
|
63
|
+
}
|
|
64
|
+
export function validateFetchTokenDrift(token, maxDriftMS) {
|
|
65
|
+
assertTokenDrift(token, maxDriftMS);
|
|
66
|
+
}
|
|
44
67
|
export function getTopLevelField(path) {
|
|
45
68
|
const secondSlash = path.indexOf('/', 1);
|
|
46
69
|
return secondSlash === -1 ? path : path.slice(0, secondSlash);
|
|
@@ -139,6 +162,160 @@ const ACCESS_PERMISSIONS_FIELD = 'accessPermissions';
|
|
|
139
162
|
}
|
|
140
163
|
return data;
|
|
141
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* A model's provider merge policy, or `null` for an ordinary model. Read
|
|
167
|
+
* straight from the store on every call (no cache) since it gates which
|
|
168
|
+
* branch a SET/tombstone apply takes. `requiredGroupFields` is the subset of
|
|
169
|
+
* `group` the model schema's own `required` array names — the fields a
|
|
170
|
+
* complete-group SET may never omit (an omitted OPTIONAL group field is
|
|
171
|
+
* legitimate: it clears that field's stored value on a winning SET instead).
|
|
172
|
+
*/ export async function resolveModelPolicy(ctx, modelID) {
|
|
173
|
+
const model = await ctx.store.getDocumentModel(modelID);
|
|
174
|
+
if (model.providerMergePolicy == null) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const schemaRequired = new Set(model.schema.required);
|
|
178
|
+
const requiredGroupFields = new Set(model.providerMergePolicy.group.filter((field)=>schemaRequired.has(field)));
|
|
179
|
+
return {
|
|
180
|
+
group: model.providerMergePolicy.group,
|
|
181
|
+
requiredGroupFields
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* `assemble`'s merge-policy variant: liveness keys on the fetch-order
|
|
186
|
+
* registers — `(setFetchHLC, live)` vs `(deleteFetchHLC, deleted)` — instead
|
|
187
|
+
* of the plain `$set`/`/` HLC registers, so a document's visible state
|
|
188
|
+
* follows the total order over provider projections rather than mutation
|
|
189
|
+
* arrival order. A tied token fails safe to deleted, mirroring the same
|
|
190
|
+
* strict-greater rule `assemble` already applies to `$set`/`/`.
|
|
191
|
+
*/ function assembleMergePolicy(fieldValues, fieldHLCs) {
|
|
192
|
+
const s = fieldHLCs[SET_FETCH_KEY];
|
|
193
|
+
const t = fieldHLCs[DELETE_FETCH_KEY];
|
|
194
|
+
const alive = s != null && (t == null || s > t);
|
|
195
|
+
if (!alive) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const data = {};
|
|
199
|
+
for (const [path, value] of Object.entries(fieldValues)){
|
|
200
|
+
if (path === DELETE_KEY || path === SET_KEY || path === DELETE_FETCH_KEY || path === SET_FETCH_KEY) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
data[path.slice(1)] = value;
|
|
204
|
+
}
|
|
205
|
+
return data;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Pure merge-policy liveness read: given a document's `field_values` cell map
|
|
209
|
+
* alone, determines whether the fetch-order registers keep a live winner or a
|
|
210
|
+
* delete winner under the total order (a tied token fails safe to deleted, per
|
|
211
|
+
* `assembleMergePolicy`). Returns `null` when neither register is present (an
|
|
212
|
+
* ordinary model, or a merge-policy document never yet touched). No store
|
|
213
|
+
* access: the `hlc` embedded in each register cell is enough to compare.
|
|
214
|
+
*/ function resolveFetchFrontier(fieldValues) {
|
|
215
|
+
const setCell = fieldValues[SET_FETCH_KEY];
|
|
216
|
+
const deleteCell = fieldValues[DELETE_FETCH_KEY];
|
|
217
|
+
const s = setCell?.hlc;
|
|
218
|
+
const t = deleteCell?.hlc;
|
|
219
|
+
if (s == null && t == null) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const alive = s != null && (t == null || s > t);
|
|
223
|
+
return alive ? {
|
|
224
|
+
hlc: s,
|
|
225
|
+
digest: setCell.digest,
|
|
226
|
+
alive: true
|
|
227
|
+
} : {
|
|
228
|
+
hlc: t,
|
|
229
|
+
digest: DELETED_STATE_DIGEST,
|
|
230
|
+
alive: false
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Pure merge-policy liveness read: the winning side's state digest alone —
|
|
235
|
+
* the live `$setFetch` digest, or `DELETED_STATE_DIGEST` when the delete side
|
|
236
|
+
* wins, or `null` when neither register is present. This is what lets
|
|
237
|
+
* `@kubun/store-graph`'s `readWinnerStateDigest` reuse the comparison after
|
|
238
|
+
* its own single-column read instead of also threading `field_hlcs`.
|
|
239
|
+
*/ export function winnerStateDigestOf(fieldValues) {
|
|
240
|
+
return resolveFetchFrontier(fieldValues)?.digest ?? null;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Fetch-token-first diagnostic classification of an incoming live SET
|
|
244
|
+
* candidate against the stored frontier (`F_hlc` = the frontier's own token,
|
|
245
|
+
* equal to `max(setFetchHLC, deleteFetchHLC)` by construction — whichever
|
|
246
|
+
* register wins carries the greater of the two). Discriminates on the
|
|
247
|
+
* candidate's token alone; state/digest is inspected only at an exact token
|
|
248
|
+
* tie, so this is exclusive and total: a below-frontier candidate is never
|
|
249
|
+
* simultaneously `dominated` (hash irrelevant beneath the frontier) and
|
|
250
|
+
* `nonconforming` (hash only inspected at an exact tie).
|
|
251
|
+
*
|
|
252
|
+
* This is the diagnostic label only — separate from which side the register
|
|
253
|
+
* actually keeps (`setWinsFrontier` below). At an exact tie with unequal
|
|
254
|
+
* digests the register still picks the lexically greater one deterministically,
|
|
255
|
+
* even though the label stays `nonconforming` for both directions: the label
|
|
256
|
+
* flags the disagreement, it does not report which side won it.
|
|
257
|
+
*/ export function classifySetOutcome(token, candidateDigest, frontier) {
|
|
258
|
+
if (frontier == null || token > frontier.hlc) {
|
|
259
|
+
return 'applied';
|
|
260
|
+
}
|
|
261
|
+
if (token < frontier.hlc) {
|
|
262
|
+
return 'dominated';
|
|
263
|
+
}
|
|
264
|
+
return candidateDigest === frontier.digest ? 'applied' : 'nonconforming';
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Full total-key winner decision for a live SET candidate against the stored
|
|
268
|
+
* frontier — `(sourceFetchHLC, stateRank, contentHash)`, live ranked below
|
|
269
|
+
* deleted. A strictly greater token always wins; a lower token never does. At
|
|
270
|
+
* an exact tie, a deleted frontier keeps the register unconditionally (rank
|
|
271
|
+
* beats a live candidate regardless of digest); a live frontier breaks the
|
|
272
|
+
* tie on the lexically greater state digest, so two replicas applying an
|
|
273
|
+
* equal-token pair of different-content live candidates in opposite arrival
|
|
274
|
+
* orders both converge on the same (greater-digest) winner rather than
|
|
275
|
+
* whichever arrived first.
|
|
276
|
+
*/ function setWinsFrontier(token, candidateDigest, frontier) {
|
|
277
|
+
if (frontier == null || token > frontier.hlc) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
if (token < frontier.hlc) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
return frontier.alive && candidateDigest > frontier.digest;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Delete-side winner decision for a tombstone candidate against the stored
|
|
287
|
+
* frontier — the `/fetch` analog of `setWinsFrontier`. A strictly greater
|
|
288
|
+
* token always wins, a lower token never does, and an exact tie is the
|
|
289
|
+
* fail-safe "delete wins" rule, unconditionally: unlike the set side, every
|
|
290
|
+
* delete reduces to the same state (`DELETED_STATE_DIGEST`), so there is no
|
|
291
|
+
* content to distinguish two equal-token deletes and no digest tiebreak is
|
|
292
|
+
* needed on this side.
|
|
293
|
+
*/ function deleteWinsFrontier(token, frontier) {
|
|
294
|
+
if (frontier == null || token > frontier.hlc) {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
if (token < frontier.hlc) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Fetch-token-first diagnostic classification of an incoming tombstone
|
|
304
|
+
* candidate against the stored frontier, mirroring `classifySetOutcome`. At
|
|
305
|
+
* an exact token tie the register always keeps the delete (fail-safe), so a
|
|
306
|
+
* tie against an already-deleted frontier is `applied` (idempotent
|
|
307
|
+
* re-tombstoning) while a tie against a live frontier is `nonconforming` —
|
|
308
|
+
* the label flags the disagreement even though the register still resolves
|
|
309
|
+
* it deterministically to the delete.
|
|
310
|
+
*/ export function classifyTombstoneOutcome(token, frontier) {
|
|
311
|
+
if (frontier == null || token > frontier.hlc) {
|
|
312
|
+
return 'applied';
|
|
313
|
+
}
|
|
314
|
+
if (token < frontier.hlc) {
|
|
315
|
+
return 'dominated';
|
|
316
|
+
}
|
|
317
|
+
return frontier.alive ? 'nonconforming' : 'applied';
|
|
318
|
+
}
|
|
142
319
|
export async function applyChangeMutation(ctx, mutation) {
|
|
143
320
|
const id = DocumentID.fromString(mutation.sub);
|
|
144
321
|
const docID = id.toString();
|
|
@@ -158,9 +335,10 @@ export async function applyChangeMutation(ctx, mutation) {
|
|
|
158
335
|
validateHLCDrift(mutation.hlc, ctx.maxDriftMS);
|
|
159
336
|
}
|
|
160
337
|
const modelID = id.model.toString();
|
|
161
|
-
const [storedHLCs, storedValues] = await Promise.all([
|
|
338
|
+
const [storedHLCs, storedValues, policy] = await Promise.all([
|
|
162
339
|
ctx.store.getFieldHLCs(docID),
|
|
163
|
-
ctx.store.getFieldValues(docID)
|
|
340
|
+
ctx.store.getFieldValues(docID),
|
|
341
|
+
resolveModelPolicy(ctx, modelID)
|
|
164
342
|
]);
|
|
165
343
|
const fieldHLCs = {
|
|
166
344
|
...storedHLCs ?? {}
|
|
@@ -173,8 +351,20 @@ export async function applyChangeMutation(ctx, mutation) {
|
|
|
173
351
|
// A delete is a single root replace to null. It only advances the tombstone
|
|
174
352
|
// timestamp; it never erases retained field values or field timestamps, so a
|
|
175
353
|
// later set above the tombstone can revive the exact prior field set.
|
|
176
|
-
const
|
|
354
|
+
const firstPatch = mutation.patch[0];
|
|
355
|
+
const isDelete = mutation.patch.length === 1 && firstPatch != null && firstPatch.op === 'replace' && firstPatch.path === DELETE_KEY && firstPatch.value === null;
|
|
177
356
|
if (isDelete) {
|
|
357
|
+
// A merge-policy document's liveness lives in the fetch-order registers
|
|
358
|
+
// (`$setFetch`/`/fetch`), which this generic delete CHANGE never advances:
|
|
359
|
+
// it would null the visible data while leaving the live register standing,
|
|
360
|
+
// so a later provider SET or any outside-group CHANGE would resurrect the
|
|
361
|
+
// document with stale group data. Reject it and force the deletion through
|
|
362
|
+
// the fetch-token tombstone path (which advances `/fetch`). A conforming
|
|
363
|
+
// tombstone reaches `applyTombstoneMutation`, not this delete-CHANGE path,
|
|
364
|
+
// so it is unaffected.
|
|
365
|
+
if (policy != null) {
|
|
366
|
+
throw new MergePolicyRemoveRequiresTombstoneError(docID);
|
|
367
|
+
}
|
|
178
368
|
fieldHLCs[DELETE_KEY] = t == null || mutation.hlc > t ? mutation.hlc : t;
|
|
179
369
|
const assembled = assemble(fieldValues, fieldHLCs);
|
|
180
370
|
// The gate guards state-changing applies. A delete can only move a live
|
|
@@ -188,15 +378,19 @@ export async function applyChangeMutation(ctx, mutation) {
|
|
|
188
378
|
};
|
|
189
379
|
const allowed = await ctx.postStateGate(postState);
|
|
190
380
|
if (!allowed) {
|
|
191
|
-
return
|
|
381
|
+
return {
|
|
382
|
+
document: null
|
|
383
|
+
};
|
|
192
384
|
}
|
|
193
385
|
}
|
|
194
386
|
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
195
|
-
return
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
387
|
+
return {
|
|
388
|
+
document: await ctx.store.saveDocument({
|
|
389
|
+
id,
|
|
390
|
+
existing: doc,
|
|
391
|
+
data: assembled
|
|
392
|
+
})
|
|
393
|
+
};
|
|
200
394
|
}
|
|
201
395
|
const validator = await getDocumentValidator(ctx, modelID);
|
|
202
396
|
// Group patches by top-level field for LWW comparison.
|
|
@@ -212,16 +406,41 @@ export async function applyChangeMutation(ctx, mutation) {
|
|
|
212
406
|
]);
|
|
213
407
|
}
|
|
214
408
|
}
|
|
409
|
+
// Single-authority gate: a merge-policy model's provider group is governed
|
|
410
|
+
// atomically by the fetch-order registers, so it must never receive a local
|
|
411
|
+
// HLC-ordered CHANGE (mixing the two orderings on the same fields would be
|
|
412
|
+
// noncommutative). A field outside the group is unaffected.
|
|
413
|
+
const groupFieldSet = policy != null ? new Set(policy.group) : null;
|
|
414
|
+
if (groupFieldSet != null) {
|
|
415
|
+
for (const path of patchesByField.keys()){
|
|
416
|
+
const field = path.slice(1);
|
|
417
|
+
if (groupFieldSet.has(field)) {
|
|
418
|
+
throw new ProviderGroupChangeRejectedError(docID, field);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
215
422
|
// A change field write wins when its HLC beats the field's current HLC and
|
|
216
423
|
// also strictly beats the set timestamp. Losing the set comparison means a
|
|
217
424
|
// newer (or concurrent) set already authored the field set, so a refining
|
|
218
425
|
// change cannot override it.
|
|
426
|
+
//
|
|
427
|
+
// Two kinds of field drop the `beatsSet` term and order by their own
|
|
428
|
+
// per-field HLC alone: the reserved `accessPermissions` field (engine
|
|
429
|
+
// metadata orthogonal to provider content, on every model) and, on a
|
|
430
|
+
// merge-policy model, any field outside its provider group (a
|
|
431
|
+
// local-annotation field — group fields never reach here, rejected above).
|
|
432
|
+
// Coupling either to the provider group's `$set`/fetch-order frontier would
|
|
433
|
+
// make acceptance depend on that frontier's arrival order relative to the
|
|
434
|
+
// change, which is noncommutative. An ordinary model's non-reserved fields
|
|
435
|
+
// keep the coupled gate unchanged.
|
|
219
436
|
const winningFields = [];
|
|
220
437
|
const winningPatches = [];
|
|
221
438
|
for (const [path, patches] of patchesByField){
|
|
439
|
+
const field = path.slice(1);
|
|
222
440
|
const currentHLC = fieldHLCs[path];
|
|
223
441
|
const beatsField = currentHLC == null || mutation.hlc > currentHLC;
|
|
224
|
-
const
|
|
442
|
+
const decoupled = field === ACCESS_PERMISSIONS_FIELD || groupFieldSet != null;
|
|
443
|
+
const beatsSet = decoupled || s == null || mutation.hlc > s;
|
|
225
444
|
if (beatsField && beatsSet) {
|
|
226
445
|
winningFields.push(path);
|
|
227
446
|
winningPatches.push(...patches);
|
|
@@ -229,14 +448,16 @@ export async function applyChangeMutation(ctx, mutation) {
|
|
|
229
448
|
}
|
|
230
449
|
// No winning field means no state change; the gate is not invoked.
|
|
231
450
|
if (winningFields.length === 0) {
|
|
232
|
-
return
|
|
451
|
+
return {
|
|
452
|
+
document: doc
|
|
453
|
+
};
|
|
233
454
|
}
|
|
234
455
|
// Reconstruct the full prior field set from retained values so the validator
|
|
235
456
|
// always sees a complete object: a set carried a fully validated document and
|
|
236
457
|
// a change only refines fields it already authored.
|
|
237
458
|
const base = {};
|
|
238
459
|
for (const [path, value] of Object.entries(fieldValues)){
|
|
239
|
-
if (path === DELETE_KEY || path === SET_KEY) {
|
|
460
|
+
if (path === DELETE_KEY || path === SET_KEY || path === SET_FETCH_KEY || path === DELETE_FETCH_KEY) {
|
|
240
461
|
continue;
|
|
241
462
|
}
|
|
242
463
|
base[path.slice(1)] = value;
|
|
@@ -270,7 +491,12 @@ export async function applyChangeMutation(ctx, mutation) {
|
|
|
270
491
|
fieldValues[path] = validated[field];
|
|
271
492
|
fieldHLCs[path] = mutation.hlc;
|
|
272
493
|
}
|
|
273
|
-
|
|
494
|
+
// A merge-policy document's liveness and visible data follow the
|
|
495
|
+
// fetch-order registers, not `$set`/`/` — mirrors `applySetMutation`'s same
|
|
496
|
+
// choice. The registers themselves are untouched by this change (the group
|
|
497
|
+
// never reaches here, rejected above), so this only affects how the
|
|
498
|
+
// resulting data is assembled and which reserved keys are excluded from it.
|
|
499
|
+
const assembled = policy == null ? assemble(fieldValues, fieldHLCs) : assembleMergePolicy(fieldValues, fieldHLCs);
|
|
274
500
|
// Synthesize the post-state for gate evaluation BEFORE any write
|
|
275
501
|
// (updateFieldHLCs + saveDocument). The cloned node is dropped on deny —
|
|
276
502
|
// engine state stays identical to pre-apply.
|
|
@@ -281,15 +507,19 @@ export async function applyChangeMutation(ctx, mutation) {
|
|
|
281
507
|
};
|
|
282
508
|
const allowed = await ctx.postStateGate(postState);
|
|
283
509
|
if (!allowed) {
|
|
284
|
-
return
|
|
510
|
+
return {
|
|
511
|
+
document: null
|
|
512
|
+
};
|
|
285
513
|
}
|
|
286
514
|
}
|
|
287
515
|
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
288
|
-
return
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
516
|
+
return {
|
|
517
|
+
document: await ctx.store.saveDocument({
|
|
518
|
+
id,
|
|
519
|
+
existing: doc,
|
|
520
|
+
data: assembled
|
|
521
|
+
})
|
|
522
|
+
};
|
|
293
523
|
}
|
|
294
524
|
export async function applySetMutation(ctx, mutation) {
|
|
295
525
|
const owner = mutation.aud ?? mutation.iss;
|
|
@@ -307,12 +537,36 @@ export async function applySetMutation(ctx, mutation) {
|
|
|
307
537
|
if (ctx.maxDriftMS != null) {
|
|
308
538
|
validateHLCDrift(mutation.hlc, ctx.maxDriftMS);
|
|
309
539
|
}
|
|
310
|
-
const [doc, validator, storedHLCs, storedValues] = await Promise.all([
|
|
540
|
+
const [doc, validator, storedHLCs, storedValues, policy] = await Promise.all([
|
|
311
541
|
ctx.store.getDocument(id),
|
|
312
542
|
getDocumentValidator(ctx, modelID),
|
|
313
543
|
ctx.store.getFieldHLCs(docID),
|
|
314
|
-
ctx.store.getFieldValues(docID)
|
|
544
|
+
ctx.store.getFieldValues(docID),
|
|
545
|
+
resolveModelPolicy(ctx, modelID)
|
|
315
546
|
]);
|
|
547
|
+
// A merge-policy model is single-authority over its provider group: every
|
|
548
|
+
// write carries the fetch token that orders it, so an incoming write with
|
|
549
|
+
// no token is rejected outright rather than classified. (Ordinary models
|
|
550
|
+
// are unaffected; `sourceFetchHLC` stays optional for them.)
|
|
551
|
+
let fetchToken = null;
|
|
552
|
+
if (policy != null) {
|
|
553
|
+
fetchToken = mutation.sourceFetchHLC ?? null;
|
|
554
|
+
if (fetchToken == null) {
|
|
555
|
+
throw new MissingFetchTokenError(docID);
|
|
556
|
+
}
|
|
557
|
+
if (ctx.maxDriftMS != null) {
|
|
558
|
+
validateFetchTokenDrift(fetchToken, ctx.maxDriftMS);
|
|
559
|
+
}
|
|
560
|
+
// Complete-group-or-reject: a merge-policy SET is an atomic replacement
|
|
561
|
+
// of the whole provider group, so every schema-required group field must
|
|
562
|
+
// be present. An OMITTED optional group field is legitimate — it clears
|
|
563
|
+
// that field's stored value when this SET wins the frontier, below.
|
|
564
|
+
for (const field of policy.requiredGroupFields){
|
|
565
|
+
if (!(field in mutation.data)) {
|
|
566
|
+
throw new IncompleteProviderGroupError(docID, field);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
316
570
|
// Reserved fields are engine metadata the model schema never declares. Split
|
|
317
571
|
// them out before validating the model-declared remainder, then re-attach so
|
|
318
572
|
// they persist unchanged.
|
|
@@ -360,27 +614,112 @@ export async function applySetMutation(ctx, mutation) {
|
|
|
360
614
|
if (doc != null && doc.owner !== owner) {
|
|
361
615
|
throw new OwnerChangeError(id.toString(), doc.owner, owner);
|
|
362
616
|
}
|
|
363
|
-
// Per-field LWW: a set field write wins when its HLC beats the field's
|
|
364
|
-
// current HLC. Winning writes update both the retained value and timestamp.
|
|
365
617
|
let changed = false;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
618
|
+
let setChanged = false;
|
|
619
|
+
let mergeFields = null;
|
|
620
|
+
if (policy == null) {
|
|
621
|
+
// Per-field LWW: a set field write wins when its HLC beats the field's
|
|
622
|
+
// current HLC. Winning writes update both the retained value and timestamp.
|
|
623
|
+
for (const [field, value] of Object.entries(data)){
|
|
624
|
+
const path = `/${field}`;
|
|
625
|
+
const currentHLC = fieldHLCs[path];
|
|
626
|
+
if (currentHLC == null || mutation.hlc > currentHLC) {
|
|
627
|
+
fieldValues[path] = value;
|
|
628
|
+
fieldHLCs[path] = mutation.hlc;
|
|
629
|
+
changed = true;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// The set timestamp advances to the greatest set HLC observed.
|
|
633
|
+
const previousSet = s;
|
|
634
|
+
fieldHLCs[SET_KEY] = s == null || mutation.hlc > s ? mutation.hlc : s;
|
|
635
|
+
setChanged = fieldHLCs[SET_KEY] !== previousSet;
|
|
636
|
+
} else {
|
|
637
|
+
const token = fetchToken;
|
|
638
|
+
const groupFields = new Set(policy.group);
|
|
639
|
+
// Local-annotation fields (outside the provider group) keep plain
|
|
640
|
+
// per-field HLC LWW, exactly like an ordinary model's fields.
|
|
641
|
+
for (const [field, value] of Object.entries(data)){
|
|
642
|
+
if (groupFields.has(field)) {
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const path = `/${field}`;
|
|
646
|
+
const currentHLC = fieldHLCs[path];
|
|
647
|
+
if (currentHLC == null || mutation.hlc > currentHLC) {
|
|
648
|
+
fieldValues[path] = value;
|
|
649
|
+
fieldHLCs[path] = mutation.hlc;
|
|
650
|
+
changed = true;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// The candidate's complete-group state digest, computed only from the
|
|
654
|
+
// group fields present in this SET — an omitted optional field is not
|
|
655
|
+
// part of the candidate's group content.
|
|
656
|
+
const group = {};
|
|
657
|
+
for (const field of policy.group){
|
|
658
|
+
if (field in data) {
|
|
659
|
+
group[field] = data[field];
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const candidateDigest = stateDigest({
|
|
663
|
+
state: 'live',
|
|
664
|
+
group
|
|
665
|
+
});
|
|
666
|
+
const frontier = resolveFetchFrontier(fieldValues);
|
|
667
|
+
const outcome = classifySetOutcome(token, candidateDigest, frontier);
|
|
668
|
+
// The provider group is governed atomically by the total order over
|
|
669
|
+
// (fetch token, state rank, content digest): the greatest token wins
|
|
670
|
+
// outright, regardless of arrival order and regardless of the mutation's
|
|
671
|
+
// own HLC. `setWinsFrontier` also resolves an exact-token tie — a
|
|
672
|
+
// deleted frontier keeps the register (rank beats a live candidate), a
|
|
673
|
+
// live frontier breaks the tie on the lexically greater digest so
|
|
674
|
+
// replicas applying an equal-token pair of different-content candidates
|
|
675
|
+
// in either arrival order converge on the same winner.
|
|
676
|
+
if (setWinsFrontier(token, candidateDigest, frontier)) {
|
|
677
|
+
// A winning SET is an atomic replacement of the whole group, not a
|
|
678
|
+
// merge into it: a group field present in stored state but absent
|
|
679
|
+
// from this SET (necessarily an optional field — a required one would
|
|
680
|
+
// have already been rejected above) is cleared rather than left stale.
|
|
681
|
+
for (const field of policy.group){
|
|
682
|
+
const path = `/${field}`;
|
|
683
|
+
if (field in data) {
|
|
684
|
+
fieldValues[path] = data[field];
|
|
685
|
+
fieldHLCs[path] = token;
|
|
686
|
+
} else {
|
|
687
|
+
delete fieldValues[path];
|
|
688
|
+
delete fieldHLCs[path];
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
fieldValues[SET_FETCH_KEY] = {
|
|
692
|
+
hlc: token,
|
|
693
|
+
digest: candidateDigest
|
|
694
|
+
};
|
|
695
|
+
fieldHLCs[SET_FETCH_KEY] = token;
|
|
696
|
+
setChanged = true;
|
|
697
|
+
mergeFields = {
|
|
698
|
+
outcome,
|
|
699
|
+
candidateDigest,
|
|
700
|
+
winnerDigest: candidateDigest,
|
|
701
|
+
winnerChanged: frontier?.digest !== candidateDigest
|
|
702
|
+
};
|
|
703
|
+
} else {
|
|
704
|
+
mergeFields = {
|
|
705
|
+
outcome,
|
|
706
|
+
candidateDigest,
|
|
707
|
+
winnerDigest: frontier?.digest ?? candidateDigest,
|
|
708
|
+
winnerChanged: false
|
|
709
|
+
};
|
|
373
710
|
}
|
|
374
711
|
}
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
712
|
+
const assembled = policy == null ? assemble(fieldValues, fieldHLCs) : assembleMergePolicy(fieldValues, fieldHLCs);
|
|
713
|
+
const toResult = (document)=>mergeFields == null ? {
|
|
714
|
+
document
|
|
715
|
+
} : {
|
|
716
|
+
document,
|
|
717
|
+
...mergeFields
|
|
718
|
+
};
|
|
380
719
|
// Nothing changed: neither a field value nor the liveness-bearing set
|
|
381
720
|
// timestamp moved. The persisted node already reflects this state.
|
|
382
721
|
if (!changed && !setChanged) {
|
|
383
|
-
return doc ?? null;
|
|
722
|
+
return toResult(doc ?? null);
|
|
384
723
|
}
|
|
385
724
|
if (doc == null) {
|
|
386
725
|
if (ctx.postStateGate != null) {
|
|
@@ -390,7 +729,9 @@ export async function applySetMutation(ctx, mutation) {
|
|
|
390
729
|
};
|
|
391
730
|
const allowed = await ctx.postStateGate(postState);
|
|
392
731
|
if (!allowed) {
|
|
393
|
-
return
|
|
732
|
+
return {
|
|
733
|
+
document: null
|
|
734
|
+
};
|
|
394
735
|
}
|
|
395
736
|
}
|
|
396
737
|
const result = await ctx.store.createDocument({
|
|
@@ -400,7 +741,7 @@ export async function applySetMutation(ctx, mutation) {
|
|
|
400
741
|
unique: fromB64(mutation.unq)
|
|
401
742
|
});
|
|
402
743
|
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
403
|
-
return result;
|
|
744
|
+
return toResult(result);
|
|
404
745
|
}
|
|
405
746
|
// Synthesize the post-state for gate evaluation BEFORE any write
|
|
406
747
|
// (updateFieldHLCs + saveDocument). The cloned node is dropped on deny.
|
|
@@ -411,15 +752,167 @@ export async function applySetMutation(ctx, mutation) {
|
|
|
411
752
|
};
|
|
412
753
|
const allowed = await ctx.postStateGate(postState);
|
|
413
754
|
if (!allowed) {
|
|
414
|
-
return
|
|
755
|
+
return {
|
|
756
|
+
document: null
|
|
757
|
+
};
|
|
415
758
|
}
|
|
416
759
|
}
|
|
417
760
|
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
418
|
-
return await ctx.store.saveDocument({
|
|
761
|
+
return toResult(await ctx.store.saveDocument({
|
|
419
762
|
id,
|
|
420
763
|
existing: doc,
|
|
421
764
|
data: assembled
|
|
422
|
-
});
|
|
765
|
+
}));
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* A fetch-token-carrying deletion on a merge-policy document's `/fetch`
|
|
769
|
+
* liveness register. Unlike `applyChangeMutation`'s delete branch, a
|
|
770
|
+
* tombstone may arrive before the document exists at all — it carries the
|
|
771
|
+
* full SET-equivalent creation-and-authorization surface (`aud`/`unq`) so a
|
|
772
|
+
* first-seen tombstone can establish a `data: null` row exactly as a SET
|
|
773
|
+
* creates state from `owner`+`unq`. Ordering is governed by the same total
|
|
774
|
+
* key as the set side (`deleteWinsFrontier`), so sets and deletes commute
|
|
775
|
+
* regardless of arrival order.
|
|
776
|
+
*
|
|
777
|
+
* A tombstone is a merge-policy-only concept — an ordinary model deletes via
|
|
778
|
+
* the `/`→null CHANGE path, never a tombstone — so a model with no
|
|
779
|
+
* `providerMergePolicy` rejects outright rather than running the fetch-order
|
|
780
|
+
* registers against a document whose liveness is governed by plain
|
|
781
|
+
* `$set`/`/` HLC registers instead.
|
|
782
|
+
*/ export async function applyTombstoneMutation(ctx, mutation) {
|
|
783
|
+
const owner = mutation.aud;
|
|
784
|
+
const id = DocumentID.fromString(mutation.sub);
|
|
785
|
+
const docID = id.toString();
|
|
786
|
+
const modelID = id.model.toString();
|
|
787
|
+
// Bind owner↔ID exactly as a SET does: the document ID must hash from the
|
|
788
|
+
// document's model, the claimed owner, and the unique value.
|
|
789
|
+
const expected = DocumentID.create(id.model, owner, fromB64(mutation.unq));
|
|
790
|
+
if (expected.toString() !== mutation.sub) {
|
|
791
|
+
throw new DocumentIDMismatchError(docID);
|
|
792
|
+
}
|
|
793
|
+
if (ctx.maxDriftMS != null) {
|
|
794
|
+
validateHLCDrift(mutation.hlc, ctx.maxDriftMS);
|
|
795
|
+
}
|
|
796
|
+
const [doc, storedHLCs, storedValues, policy] = await Promise.all([
|
|
797
|
+
ctx.store.getDocument(id),
|
|
798
|
+
ctx.store.getFieldHLCs(docID),
|
|
799
|
+
ctx.store.getFieldValues(docID),
|
|
800
|
+
resolveModelPolicy(ctx, modelID)
|
|
801
|
+
]);
|
|
802
|
+
if (policy == null) {
|
|
803
|
+
throw new TombstoneRequiresMergePolicyError(docID);
|
|
804
|
+
}
|
|
805
|
+
// A merge-policy tombstone is single-authority exactly like a merge-policy
|
|
806
|
+
// SET: the wire schema and `createTombstoneMutation` both require
|
|
807
|
+
// `sourceFetchHLC`, so this is a defensive gate against a raw/malformed
|
|
808
|
+
// mutation reaching classification with no token.
|
|
809
|
+
if (mutation.sourceFetchHLC == null) {
|
|
810
|
+
throw new MissingFetchTokenError(docID);
|
|
811
|
+
}
|
|
812
|
+
if (ctx.maxDriftMS != null) {
|
|
813
|
+
validateFetchTokenDrift(mutation.sourceFetchHLC, ctx.maxDriftMS);
|
|
814
|
+
}
|
|
815
|
+
const fieldHLCs = {
|
|
816
|
+
...storedHLCs ?? {}
|
|
817
|
+
};
|
|
818
|
+
const fieldValues = {
|
|
819
|
+
...storedValues ?? {}
|
|
820
|
+
};
|
|
821
|
+
// Synthesized post-state for a not-yet-persisted (first-seen) document,
|
|
822
|
+
// reused by the write-access check the same way `applySetMutation` reuses
|
|
823
|
+
// its own synthesized post-state.
|
|
824
|
+
const synthesizedPostState = {
|
|
825
|
+
id: docID,
|
|
826
|
+
model: modelID,
|
|
827
|
+
owner,
|
|
828
|
+
data: null,
|
|
829
|
+
createdAt: new Date(0),
|
|
830
|
+
updatedAt: null
|
|
831
|
+
};
|
|
832
|
+
// A non-owner writer must present a valid write capability — mirrors the
|
|
833
|
+
// non-owner CHANGE/SET path. Absent an injected checker, only the owner
|
|
834
|
+
// may write.
|
|
835
|
+
if (mutation.iss !== owner) {
|
|
836
|
+
const target = doc ?? synthesizedPostState;
|
|
837
|
+
const allowed = ctx.checkWriteAccess != null && await ctx.checkWriteAccess(target, mutation);
|
|
838
|
+
if (!allowed) {
|
|
839
|
+
throw new WriteAccessDeniedError(docID);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (doc != null && doc.owner !== owner) {
|
|
843
|
+
throw new OwnerChangeError(docID, doc.owner, owner);
|
|
844
|
+
}
|
|
845
|
+
const token = mutation.sourceFetchHLC;
|
|
846
|
+
const frontier = resolveFetchFrontier(fieldValues);
|
|
847
|
+
const outcome = classifyTombstoneOutcome(token, frontier);
|
|
848
|
+
// A tombstone candidate's content is always the deleted state — there is
|
|
849
|
+
// no group data to hash — so its own digest is the constant deleted marker.
|
|
850
|
+
const candidateDigest = DELETED_STATE_DIGEST;
|
|
851
|
+
if (!deleteWinsFrontier(token, frontier)) {
|
|
852
|
+
// A stale delete is dominated by a fresher candidate already at the
|
|
853
|
+
// frontier (set or delete): no state change.
|
|
854
|
+
return {
|
|
855
|
+
document: doc ?? null,
|
|
856
|
+
outcome,
|
|
857
|
+
candidateDigest,
|
|
858
|
+
winnerDigest: frontier?.digest ?? candidateDigest,
|
|
859
|
+
winnerChanged: false
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
fieldValues[DELETE_FETCH_KEY] = {
|
|
863
|
+
hlc: token
|
|
864
|
+
};
|
|
865
|
+
fieldHLCs[DELETE_FETCH_KEY] = token;
|
|
866
|
+
// A winning delete always moves the register to the constant deleted
|
|
867
|
+
// digest, regardless of what the frontier held before.
|
|
868
|
+
const winnerDigest = candidateDigest;
|
|
869
|
+
const winnerChanged = frontier?.digest !== winnerDigest;
|
|
870
|
+
const assembled = assembleMergePolicy(fieldValues, fieldHLCs);
|
|
871
|
+
if (doc == null) {
|
|
872
|
+
// First-seen tombstone: establishes a tombstoned row for a document
|
|
873
|
+
// never previously observed. A never-live -> dead transition is not a
|
|
874
|
+
// visible state change, so the post-state gate is not invoked (mirrors
|
|
875
|
+
// the ordinary delete no-op in `applyChangeMutation`).
|
|
876
|
+
const result = await ctx.store.createDocument({
|
|
877
|
+
id,
|
|
878
|
+
owner,
|
|
879
|
+
data: assembled,
|
|
880
|
+
unique: fromB64(mutation.unq)
|
|
881
|
+
});
|
|
882
|
+
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
883
|
+
return {
|
|
884
|
+
document: result,
|
|
885
|
+
outcome,
|
|
886
|
+
candidateDigest,
|
|
887
|
+
winnerDigest,
|
|
888
|
+
winnerChanged
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
// The gate guards a genuine live -> dead transition only.
|
|
892
|
+
if (doc.data != null && assembled == null && ctx.postStateGate != null) {
|
|
893
|
+
const postState = {
|
|
894
|
+
...doc,
|
|
895
|
+
data: assembled
|
|
896
|
+
};
|
|
897
|
+
const allowed = await ctx.postStateGate(postState);
|
|
898
|
+
if (!allowed) {
|
|
899
|
+
return {
|
|
900
|
+
document: null
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
905
|
+
return {
|
|
906
|
+
document: await ctx.store.saveDocument({
|
|
907
|
+
id,
|
|
908
|
+
existing: doc,
|
|
909
|
+
data: assembled
|
|
910
|
+
}),
|
|
911
|
+
outcome,
|
|
912
|
+
candidateDigest,
|
|
913
|
+
winnerDigest,
|
|
914
|
+
winnerChanged
|
|
915
|
+
};
|
|
423
916
|
}
|
|
424
917
|
export async function applyMutation(ctx, mutation) {
|
|
425
918
|
const mutationType = mutation.typ;
|
|
@@ -428,6 +921,8 @@ export async function applyMutation(ctx, mutation) {
|
|
|
428
921
|
return await applyChangeMutation(ctx, mutation);
|
|
429
922
|
case 'set':
|
|
430
923
|
return await applySetMutation(ctx, mutation);
|
|
924
|
+
case 'tombstone':
|
|
925
|
+
return await applyTombstoneMutation(ctx, mutation);
|
|
431
926
|
default:
|
|
432
927
|
throw new UnsupportedMutationTypeError(mutationType);
|
|
433
928
|
}
|