@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.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 ?? {}
@@ -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 isDelete = mutation.patch.length === 1 && mutation.patch[0].op === 'replace' && mutation.patch[0].path === DELETE_KEY && mutation.patch[0].value === null;
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 null;
381
+ return {
382
+ document: null
383
+ };
192
384
  }
193
385
  }
194
386
  await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
195
- return await ctx.store.saveDocument({
196
- id,
197
- existing: doc,
198
- data: assembled
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 beatsSet = s == null || mutation.hlc > s;
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 doc;
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
- 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);
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 null;
510
+ return {
511
+ document: null
512
+ };
285
513
  }
286
514
  }
287
515
  await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
288
- return await ctx.store.saveDocument({
289
- id,
290
- existing: doc,
291
- data: assembled
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
- for (const [field, value] of Object.entries(data)){
367
- const path = `/${field}`;
368
- const currentHLC = fieldHLCs[path];
369
- if (currentHLC == null || mutation.hlc > currentHLC) {
370
- fieldValues[path] = value;
371
- fieldHLCs[path] = mutation.hlc;
372
- 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
+ };
373
710
  }
374
711
  }
375
- // The set timestamp advances to the greatest set HLC observed.
376
- const previousSet = s;
377
- fieldHLCs[SET_KEY] = s == null || mutation.hlc > s ? mutation.hlc : s;
378
- const setChanged = fieldHLCs[SET_KEY] !== previousSet;
379
- 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
+ };
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 null;
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 null;
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
  }