@kubun/mutation 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/apply.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { HLC } from '@kubun/hlc';
2
- import { DocumentID } from '@kubun/id';
2
+ import { DocumentID, digestJSON } from '@kubun/id';
3
3
  import { isValidAccessRule, RESERVED_DOCUMENT_FIELDS } from '@kubun/protocol';
4
- import { fromB64 } from '@sozai/codec';
4
+ import { canonicalStringify, fromB64, toB64U } 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, InvalidAccessRulePatchError, InvalidDefaultAccessPermissionsError, 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,20 +48,127 @@ 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);
47
70
  }
48
71
  const RESERVED_FIELD_SET = new Set(RESERVED_DOCUMENT_FIELDS);
49
72
  const ACCESS_PERMISSIONS_FIELD = 'accessPermissions';
73
+ const DEFAULT_ACCESS_PERMISSIONS_FIELD = 'defaultAccessPermissions';
74
+ const EFFECTIVE_READ_KEY = '$readRule';
75
+ function defaultSlot(value, mutationHLC, docID) {
76
+ if (value == null || typeof value !== 'object') throw new InvalidDefaultAccessPermissionsError(docID);
77
+ const slot = value;
78
+ if (!isValidAccessRule(slot.rule) || typeof slot.hlc !== 'string' || HLC.tryParse(slot.hlc) == null || slot.hlc > mutationHLC) {
79
+ throw new InvalidDefaultAccessPermissionsError(docID);
80
+ }
81
+ return {
82
+ rule: slot.rule,
83
+ hlc: slot.hlc
84
+ };
85
+ }
86
+ function applyDefaultSlot(value, mutationHLC, docID, values, stamps) {
87
+ const slot = defaultSlot(value, mutationHLC, docID);
88
+ const path = `/${DEFAULT_ACCESS_PERMISSIONS_FIELD}`;
89
+ const current = stamps[path];
90
+ if (current != null && (slot.hlc < current || slot.hlc === current && (values[path] == null || canonicalStringify(slot) <= canonicalStringify(values[path])))) return false;
91
+ values[path] = slot;
92
+ stamps[path] = slot.hlc;
93
+ return true;
94
+ }
95
+ function applyHandPermissions(value, mutationHLC, values, stamps, touched) {
96
+ if (value != null && sanitizeAccessPermissions(value) == null) return false;
97
+ const candidate = value != null && typeof value === 'object' ? value : {};
98
+ const permissions = {
99
+ ...values['/accessPermissions']
100
+ };
101
+ let changed = false;
102
+ for (const permission of [
103
+ 'read',
104
+ 'write'
105
+ ]){
106
+ if (!touched.has(permission)) continue;
107
+ const rule = candidate[permission];
108
+ if (rule != null && !isValidAccessRule(rule)) continue;
109
+ const path = `/accessPermissions/${permission}`;
110
+ if (stamps[path] != null && mutationHLC <= stamps[path]) continue;
111
+ if (rule == null) delete permissions[permission];
112
+ else permissions[permission] = rule;
113
+ stamps[path] = mutationHLC;
114
+ changed = true;
115
+ }
116
+ if (changed) {
117
+ values['/accessPermissions'] = permissions;
118
+ const stamp = stamps['/accessPermissions'];
119
+ stamps['/accessPermissions'] = stamp == null || mutationHLC > stamp ? mutationHLC : stamp;
120
+ }
121
+ return changed;
122
+ }
123
+ function foldReadRule(values) {
124
+ const hand = values['/accessPermissions'];
125
+ const slot = values['/defaultAccessPermissions'];
126
+ const read = hand?.read ?? slot?.rule;
127
+ if (read == null) delete values[EFFECTIVE_READ_KEY];
128
+ else values[EFFECTIVE_READ_KEY] = read;
129
+ }
130
+ /** Assemble visible fields and overlay the retained effective read rule. */ export function assembleDocumentData(values) {
131
+ const data = {};
132
+ for (const [path, value] of Object.entries(values)){
133
+ if (path.startsWith('/') && path !== DELETE_KEY && path !== DELETE_FETCH_KEY) data[path.slice(1)] = value;
134
+ }
135
+ const read = values[EFFECTIVE_READ_KEY];
136
+ if (read != null) data.accessPermissions = {
137
+ ...data.accessPermissions,
138
+ read
139
+ };
140
+ return data;
141
+ }
142
+ function setNeedsShare(data) {
143
+ return ACCESS_PERMISSIONS_FIELD in data || DEFAULT_ACCESS_PERMISSIONS_FIELD in data;
144
+ }
145
+ function changeNeedsShare(patch) {
146
+ return patch.some((item)=>[
147
+ '/accessPermissions',
148
+ '/defaultAccessPermissions'
149
+ ].includes(getTopLevelField(item.path)));
150
+ }
151
+ function validateAccessRulePatches(mutation) {
152
+ for (const patch of mutation.patch){
153
+ const field = getTopLevelField(patch.path);
154
+ const targetsSlot = field === '/accessPermissions' || field === '/defaultAccessPermissions';
155
+ const allowedPath = [
156
+ '/accessPermissions',
157
+ '/accessPermissions/read',
158
+ '/accessPermissions/write',
159
+ '/defaultAccessPermissions'
160
+ ].includes(patch.path);
161
+ const source = 'from' in patch && patch.from != null ? getTopLevelField(patch.from) : null;
162
+ if (targetsSlot && (!allowedPath || ![
163
+ 'add',
164
+ 'replace',
165
+ 'set',
166
+ 'remove'
167
+ ].includes(patch.op)) || source === '/accessPermissions' || source === '/defaultAccessPermissions') throw new InvalidAccessRulePatchError({
168
+ docID: mutation.sub
169
+ });
170
+ }
171
+ }
50
172
  /**
51
173
  * Salvage a candidate `accessPermissions` value per rule, mirroring the engine
52
174
  * parser (`parseDocumentAccessPermissions`): a malformed `read` or `write` rule
@@ -63,7 +185,7 @@ const ACCESS_PERMISSIONS_FIELD = 'accessPermissions';
63
185
  const perms = value;
64
186
  const readValid = perms.read !== undefined && isValidAccessRule(perms.read);
65
187
  const writeValid = perms.write !== undefined && isValidAccessRule(perms.write);
66
- if (!readValid && !writeValid) {
188
+ if (!readValid && !writeValid && Object.keys(perms).length > 0) {
67
189
  return null;
68
190
  }
69
191
  const sanitized = {
@@ -116,6 +238,19 @@ const ACCESS_PERMISSIONS_FIELD = 'accessPermissions';
116
238
  droppedReserved
117
239
  };
118
240
  }
241
+ export function validateSetData(validator, rawData, documentID) {
242
+ const { model, reserved } = splitReservedFields(rawData);
243
+ try {
244
+ return {
245
+ ...asType(validator, model),
246
+ ...reserved
247
+ };
248
+ } catch (err) {
249
+ throw new MutationSchemaValidationError(documentID, {
250
+ cause: err
251
+ });
252
+ }
253
+ }
119
254
  /**
120
255
  * Compute the visible document data from the per-field winning values and the
121
256
  * reserved set/delete timestamps. A document is alive when it carries a set
@@ -130,14 +265,168 @@ const ACCESS_PERMISSIONS_FIELD = 'accessPermissions';
130
265
  if (!alive) {
131
266
  return null;
132
267
  }
133
- const data = {};
134
- for (const [path, value] of Object.entries(fieldValues)){
135
- if (path === DELETE_KEY || path === SET_KEY) {
136
- continue;
137
- }
138
- data[path.slice(1)] = value;
268
+ return assembleDocumentData(fieldValues);
269
+ }
270
+ /**
271
+ * A model's provider merge policy, or `null` for an ordinary model. Read
272
+ * straight from the store on every call (no cache) since it gates which
273
+ * branch a SET/tombstone apply takes. `requiredGroupFields` is the subset of
274
+ * `group` the model schema's own `required` array names — the fields a
275
+ * complete-group SET may never omit (an omitted OPTIONAL group field is
276
+ * legitimate: it clears that field's stored value on a winning SET instead).
277
+ */ export async function resolveModelPolicy(ctx, modelID) {
278
+ const model = await ctx.store.getDocumentModel(modelID);
279
+ if (model.providerMergePolicy == null) {
280
+ return null;
139
281
  }
140
- return data;
282
+ const schemaRequired = new Set(model.schema.required);
283
+ const requiredGroupFields = new Set(model.providerMergePolicy.group.filter((field)=>schemaRequired.has(field)));
284
+ return {
285
+ group: model.providerMergePolicy.group,
286
+ requiredGroupFields
287
+ };
288
+ }
289
+ /**
290
+ * `assemble`'s merge-policy variant: liveness keys on the fetch-order
291
+ * registers — `(setFetchHLC, live)` vs `(deleteFetchHLC, deleted)` — instead
292
+ * of the plain `$set`/`/` HLC registers, so a document's visible state
293
+ * follows the total order over provider projections rather than mutation
294
+ * arrival order. A tied token fails safe to deleted, mirroring the same
295
+ * strict-greater rule `assemble` already applies to `$set`/`/`.
296
+ */ function assembleMergePolicy(fieldValues, fieldHLCs) {
297
+ const s = fieldHLCs[SET_FETCH_KEY];
298
+ const t = fieldHLCs[DELETE_FETCH_KEY];
299
+ const alive = s != null && (t == null || s > t);
300
+ if (!alive) {
301
+ return null;
302
+ }
303
+ return assembleDocumentData(fieldValues);
304
+ }
305
+ /**
306
+ * Pure merge-policy liveness read: given a document's `field_values` cell map
307
+ * alone, determines whether the fetch-order registers keep a live winner or a
308
+ * delete winner under the total order (a tied token fails safe to deleted, per
309
+ * `assembleMergePolicy`). Returns `null` when neither register is present (an
310
+ * ordinary model, or a merge-policy document never yet touched). No store
311
+ * access: the `hlc` embedded in each register cell is enough to compare.
312
+ */ function resolveFetchFrontier(fieldValues) {
313
+ const setCell = fieldValues[SET_FETCH_KEY];
314
+ const deleteCell = fieldValues[DELETE_FETCH_KEY];
315
+ const s = setCell?.hlc;
316
+ const t = deleteCell?.hlc;
317
+ if (s == null && t == null) {
318
+ return null;
319
+ }
320
+ const alive = s != null && (t == null || s > t);
321
+ return alive ? {
322
+ hlc: s,
323
+ digest: setCell?.digest,
324
+ alive: true,
325
+ observed: setCell?.observed
326
+ } : {
327
+ hlc: t,
328
+ digest: DELETED_STATE_DIGEST,
329
+ alive: false,
330
+ observed: setCell?.observed
331
+ };
332
+ }
333
+ function omitObjectNulls(value) {
334
+ if (Array.isArray(value)) return value.map(omitObjectNulls);
335
+ if (value === null || typeof value !== 'object') return value;
336
+ const result = {};
337
+ for (const [key, item] of Object.entries(value)){
338
+ if (item !== null && item !== undefined) result[key] = omitObjectNulls(item);
339
+ }
340
+ return result;
341
+ }
342
+ export function providerGroupDigest(group) {
343
+ return toB64U(digestJSON(omitObjectNulls(group)));
344
+ }
345
+ /**
346
+ * Pure merge-policy liveness read: the winning side's state digest alone —
347
+ * the live `$setFetch` digest, or `DELETED_STATE_DIGEST` when the delete side
348
+ * wins, or `null` when neither register is present. This is what lets
349
+ * `@kubun/store-graph`'s `readWinnerStateDigest` reuse the comparison after
350
+ * its own single-column read instead of also threading `field_hlcs`.
351
+ */ export function winnerStateDigestOf(fieldValues) {
352
+ return resolveFetchFrontier(fieldValues)?.digest ?? null;
353
+ }
354
+ /**
355
+ * Fetch-token-first diagnostic classification of an incoming live SET
356
+ * candidate against the stored frontier (`F_hlc` = the frontier's own token,
357
+ * equal to `max(setFetchHLC, deleteFetchHLC)` by construction — whichever
358
+ * register wins carries the greater of the two). Discriminates on the
359
+ * candidate's token alone; state/digest is inspected only at an exact token
360
+ * tie, so this is exclusive and total: a below-frontier candidate is never
361
+ * simultaneously `dominated` (hash irrelevant beneath the frontier) and
362
+ * `nonconforming` (hash only inspected at an exact tie).
363
+ *
364
+ * This is the diagnostic label only — separate from which side the register
365
+ * actually keeps (`setWinsFrontier` below). At an exact tie with unequal
366
+ * digests the register still picks the lexically greater one deterministically,
367
+ * even though the label stays `nonconforming` for both directions: the label
368
+ * flags the disagreement, it does not report which side won it.
369
+ */ export function classifySetOutcome(token, candidateDigest, frontier) {
370
+ if (frontier == null || token > frontier.hlc) {
371
+ return 'applied';
372
+ }
373
+ if (token < frontier.hlc) {
374
+ return 'dominated';
375
+ }
376
+ return candidateDigest === frontier.digest ? 'applied' : 'nonconforming';
377
+ }
378
+ /**
379
+ * Full total-key winner decision for a live SET candidate against the stored
380
+ * frontier — `(sourceFetchHLC, stateRank, contentHash)`, live ranked below
381
+ * deleted. A strictly greater token always wins; a lower token never does. At
382
+ * an exact tie, a deleted frontier keeps the register unconditionally (rank
383
+ * beats a live candidate regardless of digest); a live frontier breaks the
384
+ * tie on the lexically greater state digest, so two replicas applying an
385
+ * equal-token pair of different-content live candidates in opposite arrival
386
+ * orders both converge on the same (greater-digest) winner rather than
387
+ * whichever arrived first.
388
+ */ function setWinsFrontier(token, candidateDigest, frontier) {
389
+ if (frontier == null || token > frontier.hlc) {
390
+ return true;
391
+ }
392
+ if (token < frontier.hlc) {
393
+ return false;
394
+ }
395
+ return frontier.alive && candidateDigest > frontier.digest;
396
+ }
397
+ /**
398
+ * Delete-side winner decision for a tombstone candidate against the stored
399
+ * frontier — the `/fetch` analog of `setWinsFrontier`. A strictly greater
400
+ * token always wins, a lower token never does, and an exact tie is the
401
+ * fail-safe "delete wins" rule, unconditionally: unlike the set side, every
402
+ * delete reduces to the same state (`DELETED_STATE_DIGEST`), so there is no
403
+ * content to distinguish two equal-token deletes and no digest tiebreak is
404
+ * needed on this side.
405
+ */ function deleteWinsFrontier(token, frontier) {
406
+ if (frontier == null || token > frontier.hlc) {
407
+ return true;
408
+ }
409
+ if (token < frontier.hlc) {
410
+ return false;
411
+ }
412
+ return true;
413
+ }
414
+ /**
415
+ * Fetch-token-first diagnostic classification of an incoming tombstone
416
+ * candidate against the stored frontier, mirroring `classifySetOutcome`. At
417
+ * an exact token tie the register always keeps the delete (fail-safe), so a
418
+ * tie against an already-deleted frontier is `applied` (idempotent
419
+ * re-tombstoning) while a tie against a live frontier is `nonconforming` —
420
+ * the label flags the disagreement even though the register still resolves
421
+ * it deterministically to the delete.
422
+ */ export function classifyTombstoneOutcome(token, frontier) {
423
+ if (frontier == null || token > frontier.hlc) {
424
+ return 'applied';
425
+ }
426
+ if (token < frontier.hlc) {
427
+ return 'dominated';
428
+ }
429
+ return frontier.alive ? 'nonconforming' : 'applied';
141
430
  }
142
431
  export async function applyChangeMutation(ctx, mutation) {
143
432
  const id = DocumentID.fromString(mutation.sub);
@@ -153,14 +442,20 @@ export async function applyChangeMutation(ctx, mutation) {
153
442
  if (!allowed) {
154
443
  throw new WriteAccessDeniedError(docID);
155
444
  }
445
+ if (changeNeedsShare(mutation.patch)) {
446
+ const shared = ctx.checkShareAccess != null && await ctx.checkShareAccess(doc, mutation);
447
+ if (!shared) throw new WriteAccessDeniedError(docID);
448
+ }
156
449
  }
157
450
  if (ctx.maxDriftMS != null) {
158
451
  validateHLCDrift(mutation.hlc, ctx.maxDriftMS);
159
452
  }
453
+ validateAccessRulePatches(mutation);
160
454
  const modelID = id.model.toString();
161
- const [storedHLCs, storedValues] = await Promise.all([
455
+ const [storedHLCs, storedValues, policy] = await Promise.all([
162
456
  ctx.store.getFieldHLCs(docID),
163
- ctx.store.getFieldValues(docID)
457
+ ctx.store.getFieldValues(docID),
458
+ resolveModelPolicy(ctx, modelID)
164
459
  ]);
165
460
  const fieldHLCs = {
166
461
  ...storedHLCs ?? {}
@@ -176,6 +471,17 @@ export async function applyChangeMutation(ctx, mutation) {
176
471
  const firstPatch = mutation.patch[0];
177
472
  const isDelete = mutation.patch.length === 1 && firstPatch != null && firstPatch.op === 'replace' && firstPatch.path === DELETE_KEY && firstPatch.value === null;
178
473
  if (isDelete) {
474
+ // A merge-policy document's liveness lives in the fetch-order registers
475
+ // (`$setFetch`/`/fetch`), which this generic delete CHANGE never advances:
476
+ // it would null the visible data while leaving the live register standing,
477
+ // so a later provider SET or any outside-group CHANGE would resurrect the
478
+ // document with stale group data. Reject it and force the deletion through
479
+ // the fetch-token tombstone path (which advances `/fetch`). A conforming
480
+ // tombstone reaches `applyTombstoneMutation`, not this delete-CHANGE path,
481
+ // so it is unaffected.
482
+ if (policy != null) {
483
+ throw new MergePolicyRemoveRequiresTombstoneError(docID);
484
+ }
179
485
  fieldHLCs[DELETE_KEY] = t == null || mutation.hlc > t ? mutation.hlc : t;
180
486
  const assembled = assemble(fieldValues, fieldHLCs);
181
487
  // The gate guards state-changing applies. A delete can only move a live
@@ -187,17 +493,24 @@ export async function applyChangeMutation(ctx, mutation) {
187
493
  ...doc,
188
494
  data: assembled
189
495
  };
190
- const allowed = await ctx.postStateGate(postState);
496
+ const allowed = await ctx.postStateGate(postState, {
497
+ previous: doc,
498
+ mutation
499
+ });
191
500
  if (!allowed) {
192
- return null;
501
+ return {
502
+ document: null
503
+ };
193
504
  }
194
505
  }
195
506
  await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
196
- return await ctx.store.saveDocument({
197
- id,
198
- existing: doc,
199
- data: assembled
200
- });
507
+ return {
508
+ document: await ctx.store.saveDocument({
509
+ id,
510
+ existing: doc,
511
+ data: assembled
512
+ })
513
+ };
201
514
  }
202
515
  const validator = await getDocumentValidator(ctx, modelID);
203
516
  // Group patches by top-level field for LWW comparison.
@@ -213,31 +526,86 @@ export async function applyChangeMutation(ctx, mutation) {
213
526
  ]);
214
527
  }
215
528
  }
529
+ // Single-authority gate: a merge-policy model's provider group is governed
530
+ // atomically by the fetch-order registers, so it must never receive a local
531
+ // HLC-ordered CHANGE (mixing the two orderings on the same fields would be
532
+ // noncommutative). A field outside the group is unaffected.
533
+ const groupFieldSet = policy != null ? new Set(policy.group) : null;
534
+ if (groupFieldSet != null) {
535
+ for (const path of patchesByField.keys()){
536
+ const field = path.slice(1);
537
+ if (groupFieldSet.has(field)) {
538
+ throw new ProviderGroupChangeRejectedError(docID, field);
539
+ }
540
+ }
541
+ }
542
+ let permissionsChanged = false;
543
+ const permissionPatches = patchesByField.get('/accessPermissions');
544
+ if (permissionPatches != null) {
545
+ const candidate = {
546
+ accessPermissions: structuredClone(fieldValues['/accessPermissions'] ?? {})
547
+ };
548
+ applyPatches(candidate, permissionPatches, false);
549
+ const touched = new Set();
550
+ for (const patch of permissionPatches){
551
+ if (patch.path === '/accessPermissions') {
552
+ touched.add('read');
553
+ touched.add('write');
554
+ } else touched.add(patch.path.split('/')[2] ?? '');
555
+ }
556
+ permissionsChanged = applyHandPermissions(candidate.accessPermissions, mutation.hlc, fieldValues, fieldHLCs, touched);
557
+ patchesByField.delete('/accessPermissions');
558
+ }
559
+ let defaultChanged = false;
560
+ const defaultPatches = patchesByField.get('/defaultAccessPermissions');
561
+ if (defaultPatches != null) {
562
+ const candidate = {
563
+ defaultAccessPermissions: structuredClone(fieldValues['/defaultAccessPermissions'])
564
+ };
565
+ applyPatches(candidate, defaultPatches, false);
566
+ if (!('defaultAccessPermissions' in candidate)) {
567
+ const path = '/defaultAccessPermissions';
568
+ if (fieldHLCs[path] == null || mutation.hlc >= fieldHLCs[path]) {
569
+ delete fieldValues[path];
570
+ fieldHLCs[path] = mutation.hlc;
571
+ defaultChanged = true;
572
+ }
573
+ } else {
574
+ defaultChanged = applyDefaultSlot(candidate.defaultAccessPermissions, mutation.hlc, docID, fieldValues, fieldHLCs);
575
+ }
576
+ patchesByField.delete('/defaultAccessPermissions');
577
+ }
216
578
  // A change field write wins when its HLC beats the field's current HLC and
217
579
  // also strictly beats the set timestamp. Losing the set comparison means a
218
580
  // newer (or concurrent) set already authored the field set, so a refining
219
581
  // change cannot override it.
582
+ //
583
+ // Access slots were ordered independently above. Provider-local annotations
584
+ // also order independently of the provider group's set frontier.
220
585
  const winningFields = [];
221
586
  const winningPatches = [];
222
587
  for (const [path, patches] of patchesByField){
223
588
  const currentHLC = fieldHLCs[path];
224
589
  const beatsField = currentHLC == null || mutation.hlc > currentHLC;
225
- const beatsSet = s == null || mutation.hlc > s;
590
+ const decoupled = groupFieldSet != null;
591
+ const beatsSet = decoupled || s == null || mutation.hlc > s;
226
592
  if (beatsField && beatsSet) {
227
593
  winningFields.push(path);
228
594
  winningPatches.push(...patches);
229
595
  }
230
596
  }
231
597
  // No winning field means no state change; the gate is not invoked.
232
- if (winningFields.length === 0) {
233
- return doc;
598
+ if (winningFields.length === 0 && !defaultChanged && !permissionsChanged) {
599
+ return {
600
+ document: doc
601
+ };
234
602
  }
235
603
  // Reconstruct the full prior field set from retained values so the validator
236
604
  // always sees a complete object: a set carried a fully validated document and
237
605
  // a change only refines fields it already authored.
238
606
  const base = {};
239
607
  for (const [path, value] of Object.entries(fieldValues)){
240
- if (path === DELETE_KEY || path === SET_KEY) {
608
+ if (path === DELETE_KEY || path === SET_KEY || path === SET_FETCH_KEY || path === DELETE_FETCH_KEY || path === EFFECTIVE_READ_KEY) {
241
609
  continue;
242
610
  }
243
611
  base[path.slice(1)] = value;
@@ -271,7 +639,13 @@ export async function applyChangeMutation(ctx, mutation) {
271
639
  fieldValues[path] = validated[field];
272
640
  fieldHLCs[path] = mutation.hlc;
273
641
  }
274
- const assembled = assemble(fieldValues, fieldHLCs);
642
+ foldReadRule(fieldValues);
643
+ // A merge-policy document's liveness and visible data follow the
644
+ // fetch-order registers, not `$set`/`/` — mirrors `applySetMutation`'s same
645
+ // choice. The registers themselves are untouched by this change (the group
646
+ // never reaches here, rejected above), so this only affects how the
647
+ // resulting data is assembled and which reserved keys are excluded from it.
648
+ const assembled = policy == null ? assemble(fieldValues, fieldHLCs) : assembleMergePolicy(fieldValues, fieldHLCs);
275
649
  // Synthesize the post-state for gate evaluation BEFORE any write
276
650
  // (updateFieldHLCs + saveDocument). The cloned node is dropped on deny —
277
651
  // engine state stays identical to pre-apply.
@@ -280,17 +654,24 @@ export async function applyChangeMutation(ctx, mutation) {
280
654
  ...doc,
281
655
  data: assembled
282
656
  };
283
- const allowed = await ctx.postStateGate(postState);
657
+ const allowed = await ctx.postStateGate(postState, {
658
+ previous: doc,
659
+ mutation
660
+ });
284
661
  if (!allowed) {
285
- return null;
662
+ return {
663
+ document: null
664
+ };
286
665
  }
287
666
  }
288
667
  await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
289
- return await ctx.store.saveDocument({
290
- id,
291
- existing: doc,
292
- data: assembled
293
- });
668
+ return {
669
+ document: await ctx.store.saveDocument({
670
+ id,
671
+ existing: doc,
672
+ data: assembled
673
+ })
674
+ };
294
675
  }
295
676
  export async function applySetMutation(ctx, mutation) {
296
677
  const owner = mutation.aud ?? mutation.iss;
@@ -308,27 +689,40 @@ export async function applySetMutation(ctx, mutation) {
308
689
  if (ctx.maxDriftMS != null) {
309
690
  validateHLCDrift(mutation.hlc, ctx.maxDriftMS);
310
691
  }
311
- const [doc, validator, storedHLCs, storedValues] = await Promise.all([
692
+ const [doc, validator, storedHLCs, storedValues, policy] = await Promise.all([
312
693
  ctx.store.getDocument(id),
313
694
  getDocumentValidator(ctx, modelID),
314
695
  ctx.store.getFieldHLCs(docID),
315
- ctx.store.getFieldValues(docID)
696
+ ctx.store.getFieldValues(docID),
697
+ resolveModelPolicy(ctx, modelID)
316
698
  ]);
699
+ // A merge-policy model is single-authority over its provider group: every
700
+ // write carries the fetch token that orders it, so an incoming write with
701
+ // no token is rejected outright rather than classified. (Ordinary models
702
+ // are unaffected; `sourceFetchHLC` stays optional for them.)
703
+ let fetchToken = null;
704
+ if (policy != null) {
705
+ fetchToken = mutation.sourceFetchHLC ?? null;
706
+ if (fetchToken == null) {
707
+ throw new MissingFetchTokenError(docID);
708
+ }
709
+ if (ctx.maxDriftMS != null) {
710
+ validateFetchTokenDrift(fetchToken, ctx.maxDriftMS);
711
+ }
712
+ // Complete-group-or-reject: a merge-policy SET is an atomic replacement
713
+ // of the whole provider group, so every schema-required group field must
714
+ // be present. An OMITTED optional group field is legitimate — it clears
715
+ // that field's stored value when this SET wins the frontier, below.
716
+ for (const field of policy.requiredGroupFields){
717
+ if (!(field in mutation.data)) {
718
+ throw new IncompleteProviderGroupError(docID, field);
719
+ }
720
+ }
721
+ }
317
722
  // Reserved fields are engine metadata the model schema never declares. Split
318
723
  // them out before validating the model-declared remainder, then re-attach so
319
724
  // they persist unchanged.
320
- const { model: modelData, reserved } = splitReservedFields(mutation.data);
321
- let data;
322
- try {
323
- data = {
324
- ...asType(validator, modelData),
325
- ...reserved
326
- };
327
- } catch (err) {
328
- throw new MutationSchemaValidationError(docID, {
329
- cause: err
330
- });
331
- }
725
+ const data = validateSetData(validator, mutation.data, docID);
332
726
  const fieldHLCs = {
333
727
  ...storedHLCs ?? {}
334
728
  };
@@ -357,31 +751,129 @@ export async function applySetMutation(ctx, mutation) {
357
751
  if (!allowed) {
358
752
  throw new WriteAccessDeniedError(docID);
359
753
  }
754
+ if (setNeedsShare(mutation.data)) {
755
+ const shared = ctx.checkShareAccess != null && await ctx.checkShareAccess(target, mutation);
756
+ if (!shared) throw new WriteAccessDeniedError(docID);
757
+ }
360
758
  }
361
759
  if (doc != null && doc.owner !== owner) {
362
760
  throw new OwnerChangeError(id.toString(), doc.owner, owner);
363
761
  }
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
- 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;
762
+ let changed = DEFAULT_ACCESS_PERMISSIONS_FIELD in data && applyDefaultSlot(data.defaultAccessPermissions, mutation.hlc, docID, fieldValues, fieldHLCs);
763
+ if (ACCESS_PERMISSIONS_FIELD in data) {
764
+ const permissions = data.accessPermissions;
765
+ changed = applyHandPermissions(permissions, mutation.hlc, fieldValues, fieldHLCs, new Set(Object.keys(permissions))) || changed;
766
+ }
767
+ let setChanged = false;
768
+ let mergeFields = null;
769
+ if (policy == null) {
770
+ // Per-field LWW: a set field write wins when its HLC beats the field's
771
+ // current HLC. Winning writes update both the retained value and timestamp.
772
+ for (const [field, value] of Object.entries(data)){
773
+ if (field === DEFAULT_ACCESS_PERMISSIONS_FIELD || field === ACCESS_PERMISSIONS_FIELD) continue;
774
+ const path = `/${field}`;
775
+ const currentHLC = fieldHLCs[path];
776
+ if (currentHLC == null || mutation.hlc > currentHLC) {
777
+ fieldValues[path] = value;
778
+ fieldHLCs[path] = mutation.hlc;
779
+ changed = true;
780
+ }
781
+ }
782
+ // The set timestamp advances to the greatest set HLC observed.
783
+ const previousSet = s;
784
+ fieldHLCs[SET_KEY] = s == null || mutation.hlc > s ? mutation.hlc : s;
785
+ setChanged = fieldHLCs[SET_KEY] !== previousSet;
786
+ } else {
787
+ const token = fetchToken;
788
+ const groupFields = new Set(policy.group);
789
+ // Local-annotation fields (outside the provider group) keep plain
790
+ // per-field HLC LWW, exactly like an ordinary model's fields.
791
+ for (const [field, value] of Object.entries(data)){
792
+ if (groupFields.has(field) || field === DEFAULT_ACCESS_PERMISSIONS_FIELD || field === ACCESS_PERMISSIONS_FIELD) {
793
+ continue;
794
+ }
795
+ const path = `/${field}`;
796
+ const currentHLC = fieldHLCs[path];
797
+ if (currentHLC == null || mutation.hlc > currentHLC) {
798
+ fieldValues[path] = value;
799
+ fieldHLCs[path] = mutation.hlc;
800
+ changed = true;
801
+ }
802
+ }
803
+ // The candidate's complete-group state digest, computed only from the
804
+ // group fields present in this SET — an omitted optional field is not
805
+ // part of the candidate's group content.
806
+ const group = {};
807
+ for (const field of policy.group){
808
+ if (field in data) {
809
+ group[field] = data[field];
810
+ }
811
+ }
812
+ const candidateDigest = stateDigest({
813
+ state: 'live',
814
+ group
815
+ });
816
+ const frontier = resolveFetchFrontier(fieldValues);
817
+ const outcome = classifySetOutcome(token, candidateDigest, frontier);
818
+ // The provider group is governed atomically by the total order over
819
+ // (fetch token, state rank, content digest): the greatest token wins
820
+ // outright, regardless of arrival order and regardless of the mutation's
821
+ // own HLC. `setWinsFrontier` also resolves an exact-token tie — a
822
+ // deleted frontier keeps the register (rank beats a live candidate), a
823
+ // live frontier breaks the tie on the lexically greater digest so
824
+ // replicas applying an equal-token pair of different-content candidates
825
+ // in either arrival order converge on the same winner.
826
+ if (setWinsFrontier(token, candidateDigest, frontier)) {
827
+ // A winning SET is an atomic replacement of the whole group, not a
828
+ // merge into it: a group field present in stored state but absent
829
+ // from this SET (necessarily an optional field — a required one would
830
+ // have already been rejected above) is cleared rather than left stale.
831
+ for (const field of policy.group){
832
+ const path = `/${field}`;
833
+ if (field in data) {
834
+ fieldValues[path] = data[field];
835
+ fieldHLCs[path] = token;
836
+ } else {
837
+ delete fieldValues[path];
838
+ delete fieldHLCs[path];
839
+ }
840
+ }
841
+ fieldValues[SET_FETCH_KEY] = {
842
+ hlc: token,
843
+ digest: candidateDigest,
844
+ ...frontier?.observed !== undefined && token <= frontier.observed.fetchHLC ? {
845
+ observed: frontier.observed
846
+ } : {}
847
+ };
848
+ fieldHLCs[SET_FETCH_KEY] = token;
849
+ setChanged = true;
850
+ mergeFields = {
851
+ outcome,
852
+ candidateDigest,
853
+ winnerDigest: candidateDigest,
854
+ winnerChanged: frontier?.digest !== candidateDigest
855
+ };
856
+ } else {
857
+ mergeFields = {
858
+ outcome,
859
+ candidateDigest,
860
+ winnerDigest: frontier?.digest ?? candidateDigest,
861
+ winnerChanged: false
862
+ };
374
863
  }
375
864
  }
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);
865
+ foldReadRule(fieldValues);
866
+ const assembled = policy == null ? assemble(fieldValues, fieldHLCs) : assembleMergePolicy(fieldValues, fieldHLCs);
867
+ const toResult = (document)=>mergeFields == null ? {
868
+ document
869
+ } : {
870
+ document,
871
+ ...mergeFields
872
+ };
381
873
  // Nothing changed: neither a field value nor the liveness-bearing set
382
874
  // timestamp moved. The persisted node already reflects this state.
383
875
  if (!changed && !setChanged) {
384
- return doc ?? null;
876
+ return toResult(doc ?? null);
385
877
  }
386
878
  if (doc == null) {
387
879
  if (ctx.postStateGate != null) {
@@ -389,9 +881,14 @@ export async function applySetMutation(ctx, mutation) {
389
881
  ...synthesizedPostState,
390
882
  data: assembled
391
883
  };
392
- const allowed = await ctx.postStateGate(postState);
884
+ const allowed = await ctx.postStateGate(postState, {
885
+ previous: doc,
886
+ mutation
887
+ });
393
888
  if (!allowed) {
394
- return null;
889
+ return {
890
+ document: null
891
+ };
395
892
  }
396
893
  }
397
894
  const result = await ctx.store.createDocument({
@@ -401,7 +898,7 @@ export async function applySetMutation(ctx, mutation) {
401
898
  unique: fromB64(mutation.unq)
402
899
  });
403
900
  await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
404
- return result;
901
+ return toResult(result);
405
902
  }
406
903
  // Synthesize the post-state for gate evaluation BEFORE any write
407
904
  // (updateFieldHLCs + saveDocument). The cloned node is dropped on deny.
@@ -410,17 +907,186 @@ export async function applySetMutation(ctx, mutation) {
410
907
  ...doc,
411
908
  data: assembled
412
909
  };
413
- const allowed = await ctx.postStateGate(postState);
910
+ const allowed = await ctx.postStateGate(postState, {
911
+ previous: doc,
912
+ mutation
913
+ });
414
914
  if (!allowed) {
415
- return null;
915
+ return {
916
+ document: null
917
+ };
416
918
  }
417
919
  }
418
920
  await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
419
- return await ctx.store.saveDocument({
921
+ const saved = await ctx.store.saveDocument({
420
922
  id,
421
923
  existing: doc,
422
924
  data: assembled
423
925
  });
926
+ return toResult(saved);
927
+ }
928
+ /**
929
+ * A fetch-token-carrying deletion on a merge-policy document's `/fetch`
930
+ * liveness register. Unlike `applyChangeMutation`'s delete branch, a
931
+ * tombstone may arrive before the document exists at all — it carries the
932
+ * full SET-equivalent creation-and-authorization surface (`aud`/`unq`) so a
933
+ * first-seen tombstone can establish a `data: null` row exactly as a SET
934
+ * creates state from `owner`+`unq`. Ordering is governed by the same total
935
+ * key as the set side (`deleteWinsFrontier`), so sets and deletes commute
936
+ * regardless of arrival order.
937
+ *
938
+ * A tombstone is a merge-policy-only concept — an ordinary model deletes via
939
+ * the `/`→null CHANGE path, never a tombstone — so a model with no
940
+ * `providerMergePolicy` rejects outright rather than running the fetch-order
941
+ * registers against a document whose liveness is governed by plain
942
+ * `$set`/`/` HLC registers instead.
943
+ */ export async function applyTombstoneMutation(ctx, mutation) {
944
+ const owner = mutation.aud;
945
+ const id = DocumentID.fromString(mutation.sub);
946
+ const docID = id.toString();
947
+ const modelID = id.model.toString();
948
+ // Bind owner↔ID exactly as a SET does: the document ID must hash from the
949
+ // document's model, the claimed owner, and the unique value.
950
+ const expected = DocumentID.create(id.model, owner, fromB64(mutation.unq));
951
+ if (expected.toString() !== mutation.sub) {
952
+ throw new DocumentIDMismatchError(docID);
953
+ }
954
+ if (ctx.maxDriftMS != null) {
955
+ validateHLCDrift(mutation.hlc, ctx.maxDriftMS);
956
+ }
957
+ const [doc, storedHLCs, storedValues, policy] = await Promise.all([
958
+ ctx.store.getDocument(id),
959
+ ctx.store.getFieldHLCs(docID),
960
+ ctx.store.getFieldValues(docID),
961
+ resolveModelPolicy(ctx, modelID)
962
+ ]);
963
+ if (policy == null) {
964
+ throw new TombstoneRequiresMergePolicyError(docID);
965
+ }
966
+ // A merge-policy tombstone is single-authority exactly like a merge-policy
967
+ // SET: the wire schema and `createTombstoneMutation` both require
968
+ // `sourceFetchHLC`, so this is a defensive gate against a raw/malformed
969
+ // mutation reaching classification with no token.
970
+ if (mutation.sourceFetchHLC == null) {
971
+ throw new MissingFetchTokenError(docID);
972
+ }
973
+ if (ctx.maxDriftMS != null) {
974
+ validateFetchTokenDrift(mutation.sourceFetchHLC, ctx.maxDriftMS);
975
+ }
976
+ const fieldHLCs = {
977
+ ...storedHLCs ?? {}
978
+ };
979
+ const fieldValues = {
980
+ ...storedValues ?? {}
981
+ };
982
+ // Synthesized post-state for a not-yet-persisted (first-seen) document,
983
+ // reused by the write-access check the same way `applySetMutation` reuses
984
+ // its own synthesized post-state.
985
+ const synthesizedPostState = {
986
+ id: docID,
987
+ model: modelID,
988
+ owner,
989
+ data: null,
990
+ createdAt: new Date(0),
991
+ updatedAt: null
992
+ };
993
+ // A non-owner writer must present a valid write capability — mirrors the
994
+ // non-owner CHANGE/SET path. Absent an injected checker, only the owner
995
+ // may write.
996
+ if (mutation.iss !== owner) {
997
+ const target = doc ?? synthesizedPostState;
998
+ const allowed = ctx.checkWriteAccess != null && await ctx.checkWriteAccess(target, mutation);
999
+ if (!allowed) {
1000
+ throw new WriteAccessDeniedError(docID);
1001
+ }
1002
+ }
1003
+ if (doc != null && doc.owner !== owner) {
1004
+ throw new OwnerChangeError(docID, doc.owner, owner);
1005
+ }
1006
+ const token = mutation.sourceFetchHLC;
1007
+ const frontier = resolveFetchFrontier(fieldValues);
1008
+ const outcome = classifyTombstoneOutcome(token, frontier);
1009
+ // A tombstone candidate's content is always the deleted state — there is
1010
+ // no group data to hash — so its own digest is the constant deleted marker.
1011
+ const candidateDigest = DELETED_STATE_DIGEST;
1012
+ if (!deleteWinsFrontier(token, frontier)) {
1013
+ // A stale delete is dominated by a fresher candidate already at the
1014
+ // frontier (set or delete): no state change.
1015
+ return {
1016
+ document: doc ?? null,
1017
+ outcome,
1018
+ candidateDigest,
1019
+ winnerDigest: frontier?.digest ?? candidateDigest,
1020
+ winnerChanged: false
1021
+ };
1022
+ }
1023
+ fieldValues[DELETE_FETCH_KEY] = {
1024
+ hlc: token
1025
+ };
1026
+ if (frontier?.observed !== undefined && token > frontier.observed.fetchHLC) {
1027
+ const setCell = fieldValues[SET_FETCH_KEY];
1028
+ if (setCell !== undefined) {
1029
+ if (setCell.hlc === undefined) delete fieldValues[SET_FETCH_KEY];
1030
+ else fieldValues[SET_FETCH_KEY] = {
1031
+ hlc: setCell.hlc,
1032
+ digest: setCell.digest
1033
+ };
1034
+ }
1035
+ }
1036
+ fieldHLCs[DELETE_FETCH_KEY] = token;
1037
+ // A winning delete always moves the register to the constant deleted
1038
+ // digest, regardless of what the frontier held before.
1039
+ const winnerDigest = candidateDigest;
1040
+ const winnerChanged = frontier?.digest !== winnerDigest;
1041
+ const assembled = assembleMergePolicy(fieldValues, fieldHLCs);
1042
+ if (doc == null) {
1043
+ // First-seen tombstone: establishes a tombstoned row for a document
1044
+ // never previously observed. A never-live -> dead transition is not a
1045
+ // visible state change, so the post-state gate is not invoked (mirrors
1046
+ // the ordinary delete no-op in `applyChangeMutation`).
1047
+ const result = await ctx.store.createDocument({
1048
+ id,
1049
+ owner,
1050
+ data: assembled,
1051
+ unique: fromB64(mutation.unq)
1052
+ });
1053
+ await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
1054
+ return {
1055
+ document: result,
1056
+ outcome,
1057
+ candidateDigest,
1058
+ winnerDigest,
1059
+ winnerChanged
1060
+ };
1061
+ }
1062
+ // The gate guards a genuine live -> dead transition only.
1063
+ if (doc.data != null && assembled == null && ctx.postStateGate != null) {
1064
+ const postState = {
1065
+ ...doc,
1066
+ data: assembled
1067
+ };
1068
+ const allowed = await ctx.postStateGate(postState, {
1069
+ previous: doc,
1070
+ mutation
1071
+ });
1072
+ if (!allowed) {
1073
+ return {
1074
+ document: null
1075
+ };
1076
+ }
1077
+ }
1078
+ await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
1079
+ return {
1080
+ document: await ctx.store.saveDocument({
1081
+ id,
1082
+ existing: doc,
1083
+ data: assembled
1084
+ }),
1085
+ outcome,
1086
+ candidateDigest,
1087
+ winnerDigest,
1088
+ winnerChanged
1089
+ };
424
1090
  }
425
1091
  export async function applyMutation(ctx, mutation) {
426
1092
  const mutationType = mutation.typ;
@@ -429,6 +1095,8 @@ export async function applyMutation(ctx, mutation) {
429
1095
  return await applyChangeMutation(ctx, mutation);
430
1096
  case 'set':
431
1097
  return await applySetMutation(ctx, mutation);
1098
+ case 'tombstone':
1099
+ return await applyTombstoneMutation(ctx, mutation);
432
1100
  default:
433
1101
  throw new UnsupportedMutationTypeError(mutationType);
434
1102
  }