@kubun/mutation 0.17.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.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 validateHLCDrift(hlcString, maxDriftMS) {
37
- const ts = HLC.parse(hlcString);
38
- const now = Date.now();
39
- const drift = ts.wallTime - now;
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 ?? {}
@@ -176,6 +354,17 @@ export async function applyChangeMutation(ctx, mutation) {
176
354
  const firstPatch = mutation.patch[0];
177
355
  const isDelete = mutation.patch.length === 1 && firstPatch != null && firstPatch.op === 'replace' && firstPatch.path === DELETE_KEY && firstPatch.value === null;
178
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
+ }
179
368
  fieldHLCs[DELETE_KEY] = t == null || mutation.hlc > t ? mutation.hlc : t;
180
369
  const assembled = assemble(fieldValues, fieldHLCs);
181
370
  // The gate guards state-changing applies. A delete can only move a live
@@ -189,15 +378,19 @@ export async function applyChangeMutation(ctx, mutation) {
189
378
  };
190
379
  const allowed = await ctx.postStateGate(postState);
191
380
  if (!allowed) {
192
- return null;
381
+ return {
382
+ document: null
383
+ };
193
384
  }
194
385
  }
195
386
  await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
196
- return await ctx.store.saveDocument({
197
- id,
198
- existing: doc,
199
- data: assembled
200
- });
387
+ return {
388
+ document: await ctx.store.saveDocument({
389
+ id,
390
+ existing: doc,
391
+ data: assembled
392
+ })
393
+ };
201
394
  }
202
395
  const validator = await getDocumentValidator(ctx, modelID);
203
396
  // Group patches by top-level field for LWW comparison.
@@ -213,16 +406,41 @@ export async function applyChangeMutation(ctx, mutation) {
213
406
  ]);
214
407
  }
215
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
+ }
216
422
  // A change field write wins when its HLC beats the field's current HLC and
217
423
  // also strictly beats the set timestamp. Losing the set comparison means a
218
424
  // newer (or concurrent) set already authored the field set, so a refining
219
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.
220
436
  const winningFields = [];
221
437
  const winningPatches = [];
222
438
  for (const [path, patches] of patchesByField){
439
+ const field = path.slice(1);
223
440
  const currentHLC = fieldHLCs[path];
224
441
  const beatsField = currentHLC == null || mutation.hlc > currentHLC;
225
- const beatsSet = s == null || mutation.hlc > s;
442
+ const decoupled = field === ACCESS_PERMISSIONS_FIELD || groupFieldSet != null;
443
+ const beatsSet = decoupled || s == null || mutation.hlc > s;
226
444
  if (beatsField && beatsSet) {
227
445
  winningFields.push(path);
228
446
  winningPatches.push(...patches);
@@ -230,14 +448,16 @@ export async function applyChangeMutation(ctx, mutation) {
230
448
  }
231
449
  // No winning field means no state change; the gate is not invoked.
232
450
  if (winningFields.length === 0) {
233
- return doc;
451
+ return {
452
+ document: doc
453
+ };
234
454
  }
235
455
  // Reconstruct the full prior field set from retained values so the validator
236
456
  // always sees a complete object: a set carried a fully validated document and
237
457
  // a change only refines fields it already authored.
238
458
  const base = {};
239
459
  for (const [path, value] of Object.entries(fieldValues)){
240
- if (path === DELETE_KEY || path === SET_KEY) {
460
+ if (path === DELETE_KEY || path === SET_KEY || path === SET_FETCH_KEY || path === DELETE_FETCH_KEY) {
241
461
  continue;
242
462
  }
243
463
  base[path.slice(1)] = value;
@@ -271,7 +491,12 @@ export async function applyChangeMutation(ctx, mutation) {
271
491
  fieldValues[path] = validated[field];
272
492
  fieldHLCs[path] = mutation.hlc;
273
493
  }
274
- const assembled = assemble(fieldValues, fieldHLCs);
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);
275
500
  // Synthesize the post-state for gate evaluation BEFORE any write
276
501
  // (updateFieldHLCs + saveDocument). The cloned node is dropped on deny —
277
502
  // engine state stays identical to pre-apply.
@@ -282,15 +507,19 @@ export async function applyChangeMutation(ctx, mutation) {
282
507
  };
283
508
  const allowed = await ctx.postStateGate(postState);
284
509
  if (!allowed) {
285
- return null;
510
+ return {
511
+ document: null
512
+ };
286
513
  }
287
514
  }
288
515
  await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
289
- return await ctx.store.saveDocument({
290
- id,
291
- existing: doc,
292
- data: assembled
293
- });
516
+ return {
517
+ document: await ctx.store.saveDocument({
518
+ id,
519
+ existing: doc,
520
+ data: assembled
521
+ })
522
+ };
294
523
  }
295
524
  export async function applySetMutation(ctx, mutation) {
296
525
  const owner = mutation.aud ?? mutation.iss;
@@ -308,12 +537,36 @@ export async function applySetMutation(ctx, mutation) {
308
537
  if (ctx.maxDriftMS != null) {
309
538
  validateHLCDrift(mutation.hlc, ctx.maxDriftMS);
310
539
  }
311
- const [doc, validator, storedHLCs, storedValues] = await Promise.all([
540
+ const [doc, validator, storedHLCs, storedValues, policy] = await Promise.all([
312
541
  ctx.store.getDocument(id),
313
542
  getDocumentValidator(ctx, modelID),
314
543
  ctx.store.getFieldHLCs(docID),
315
- ctx.store.getFieldValues(docID)
544
+ ctx.store.getFieldValues(docID),
545
+ resolveModelPolicy(ctx, modelID)
316
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
+ }
317
570
  // Reserved fields are engine metadata the model schema never declares. Split
318
571
  // them out before validating the model-declared remainder, then re-attach so
319
572
  // they persist unchanged.
@@ -361,27 +614,112 @@ export async function applySetMutation(ctx, mutation) {
361
614
  if (doc != null && doc.owner !== owner) {
362
615
  throw new OwnerChangeError(id.toString(), doc.owner, owner);
363
616
  }
364
- // Per-field LWW: a set field write wins when its HLC beats the field's
365
- // current HLC. Winning writes update both the retained value and timestamp.
366
617
  let changed = false;
367
- for (const [field, value] of Object.entries(data)){
368
- const path = `/${field}`;
369
- const currentHLC = fieldHLCs[path];
370
- if (currentHLC == null || mutation.hlc > currentHLC) {
371
- fieldValues[path] = value;
372
- fieldHLCs[path] = mutation.hlc;
373
- changed = true;
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
+ };
374
710
  }
375
711
  }
376
- // The set timestamp advances to the greatest set HLC observed.
377
- const previousSet = s;
378
- fieldHLCs[SET_KEY] = s == null || mutation.hlc > s ? mutation.hlc : s;
379
- const setChanged = fieldHLCs[SET_KEY] !== previousSet;
380
- const assembled = assemble(fieldValues, fieldHLCs);
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
+ };
381
719
  // Nothing changed: neither a field value nor the liveness-bearing set
382
720
  // timestamp moved. The persisted node already reflects this state.
383
721
  if (!changed && !setChanged) {
384
- return doc ?? null;
722
+ return toResult(doc ?? null);
385
723
  }
386
724
  if (doc == null) {
387
725
  if (ctx.postStateGate != null) {
@@ -391,7 +729,9 @@ export async function applySetMutation(ctx, mutation) {
391
729
  };
392
730
  const allowed = await ctx.postStateGate(postState);
393
731
  if (!allowed) {
394
- return null;
732
+ return {
733
+ document: null
734
+ };
395
735
  }
396
736
  }
397
737
  const result = await ctx.store.createDocument({
@@ -401,7 +741,7 @@ export async function applySetMutation(ctx, mutation) {
401
741
  unique: fromB64(mutation.unq)
402
742
  });
403
743
  await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
404
- return result;
744
+ return toResult(result);
405
745
  }
406
746
  // Synthesize the post-state for gate evaluation BEFORE any write
407
747
  // (updateFieldHLCs + saveDocument). The cloned node is dropped on deny.
@@ -412,15 +752,167 @@ export async function applySetMutation(ctx, mutation) {
412
752
  };
413
753
  const allowed = await ctx.postStateGate(postState);
414
754
  if (!allowed) {
415
- return null;
755
+ return {
756
+ document: null
757
+ };
416
758
  }
417
759
  }
418
760
  await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
419
- return await ctx.store.saveDocument({
761
+ return toResult(await ctx.store.saveDocument({
420
762
  id,
421
763
  existing: doc,
422
764
  data: assembled
423
- });
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
+ };
424
916
  }
425
917
  export async function applyMutation(ctx, mutation) {
426
918
  const mutationType = mutation.typ;
@@ -429,6 +921,8 @@ export async function applyMutation(ctx, mutation) {
429
921
  return await applyChangeMutation(ctx, mutation);
430
922
  case 'set':
431
923
  return await applySetMutation(ctx, mutation);
924
+ case 'tombstone':
925
+ return await applyTombstoneMutation(ctx, mutation);
432
926
  default:
433
927
  throw new UnsupportedMutationTypeError(mutationType);
434
928
  }