@kubun/mutation 0.17.1 → 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.
@@ -0,0 +1,3 @@
1
+ import type { DocumentMutation, DocumentNode } from '@kubun/protocol';
2
+ /** An exit notice requires a live held state and cannot carry application data. */
3
+ export declare function isAccessSlotOnlyMutation(mutation: DocumentMutation, previous: DocumentNode | null): boolean;
@@ -0,0 +1,23 @@
1
+ import { canonicalStringify } from '@sozai/codec';
2
+ function withoutAccessSlots(data) {
3
+ const { accessPermissions: _hand, defaultAccessPermissions: _default, ...rest } = data;
4
+ return rest;
5
+ }
6
+ /** An exit notice requires a live held state and cannot carry application data. */ export function isAccessSlotOnlyMutation(mutation, previous) {
7
+ if (previous?.data == null || mutation.typ === 'tombstone') return false;
8
+ if (mutation.typ === 'set') {
9
+ return canonicalStringify(withoutAccessSlots(mutation.data)) === canonicalStringify(withoutAccessSlots(previous.data));
10
+ }
11
+ return mutation.patch.length > 0 && mutation.patch.every((patch)=>{
12
+ return [
13
+ '/accessPermissions',
14
+ '/accessPermissions/read',
15
+ '/accessPermissions/write',
16
+ '/defaultAccessPermissions'
17
+ ].includes(patch.path) && [
18
+ 'add',
19
+ 'replace',
20
+ 'remove'
21
+ ].includes(patch.op) && !('from' in patch);
22
+ });
23
+ }
package/lib/apply.d.ts CHANGED
@@ -2,7 +2,7 @@ import { HLC } from '@kubun/hlc';
2
2
  import type { DocumentModelID } from '@kubun/id';
3
3
  import { DocumentID } from '@kubun/id';
4
4
  import type { ChangeDocumentMutation, DocumentData, DocumentModel, DocumentMutation, DocumentNode, SetDocumentMutation, TombstoneDocumentMutation } from '@kubun/protocol';
5
- import { type Validator } from '@sozai/schema';
5
+ import type { Validator } from '@sozai/schema';
6
6
  export type DocumentValidator = Validator<DocumentData>;
7
7
  export type ValidatorsRecord = Record<string, Promise<DocumentValidator>>;
8
8
  /**
@@ -28,7 +28,11 @@ export declare const DELETE_FETCH_KEY = "/fetch";
28
28
  * effects (`saveDocument`, `createDocument`, `updateFieldHLCs`) are not
29
29
  * invoked when the gate denies, so engine state is unchanged.
30
30
  */
31
- export type PostStateGate = (postState: DocumentNode) => Promise<boolean> | boolean;
31
+ export type PostStateGateContext = {
32
+ previous: DocumentNode | null;
33
+ mutation: DocumentMutation;
34
+ };
35
+ export type PostStateGate = (postState: DocumentNode, context: PostStateGateContext) => Promise<boolean> | boolean;
32
36
  /**
33
37
  * Minimal database interface required by mutation operations.
34
38
  * This decouples @kubun/mutation from the full KubunDB/store API,
@@ -61,6 +65,7 @@ export type MutationContext = {
61
65
  * injected checker, only the owner may write (owner-only safe default).
62
66
  */
63
67
  checkWriteAccess?: (doc: DocumentNode, mutation: DocumentMutation) => Promise<boolean>;
68
+ checkShareAccess?: (doc: DocumentNode, mutation: DocumentMutation) => Promise<boolean>;
64
69
  hlc?: HLC;
65
70
  maxDriftMS?: number;
66
71
  /**
@@ -83,6 +88,9 @@ export declare function getDocumentValidator(ctx: MutationContext, id: string):
83
88
  export declare const DEFAULT_MAX_DRIFT_MS = 3600000;
84
89
  export declare function validateFetchTokenDrift(token: string, maxDriftMS: number): void;
85
90
  export declare function getTopLevelField(path: string): string;
91
+ /** Assemble visible fields and overlay the retained effective read rule. */
92
+ export declare function assembleDocumentData(values: Record<string, unknown>): DocumentData;
93
+ export declare function validateSetData(validator: DocumentValidator, rawData: Record<string, unknown>, documentID: string): DocumentData;
86
94
  /**
87
95
  * A model's provider merge policy, or `null` for an ordinary model. Read
88
96
  * straight from the store on every call (no cache) since it gates which
@@ -103,11 +111,18 @@ export declare function resolveModelPolicy(ctx: MutationContext, modelID: Docume
103
111
  * SET classifier/register-winner logic below, which also need the frontier's
104
112
  * own token and liveness to compare a candidate against it.
105
113
  */
114
+ export type ObservedProviderValue = {
115
+ fetchHLC: string;
116
+ valueDigest: string | null;
117
+ present: boolean;
118
+ };
106
119
  export type FetchFrontier = {
107
120
  hlc: string;
108
121
  digest: string;
109
122
  alive: boolean;
123
+ observed?: ObservedProviderValue;
110
124
  };
125
+ export declare function providerGroupDigest(group: Record<string, unknown>): string;
111
126
  /**
112
127
  * Pure merge-policy liveness read: the winning side's state digest alone —
113
128
  * the live `$setFetch` digest, or `DELETED_STATE_DIGEST` when the delete side
package/lib/apply.js CHANGED
@@ -1,10 +1,10 @@
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, IncompleteProviderGroupError, InvalidFetchTokenError, MergePolicyRemoveRequiresTombstoneError, MissingFetchTokenError, MutationSchemaValidationError, OwnerChangeError, ProviderGroupChangeRejectedError, TombstoneRequiresMergePolicyError, 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
8
  import { DELETED_STATE_DIGEST, stateDigest } from './state-digest.js';
9
9
  /**
10
10
  * Reserved `field_hlcs` key holding the greatest delete HLC observed for the
@@ -70,6 +70,105 @@ export function getTopLevelField(path) {
70
70
  }
71
71
  const RESERVED_FIELD_SET = new Set(RESERVED_DOCUMENT_FIELDS);
72
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
+ }
73
172
  /**
74
173
  * Salvage a candidate `accessPermissions` value per rule, mirroring the engine
75
174
  * parser (`parseDocumentAccessPermissions`): a malformed `read` or `write` rule
@@ -86,7 +185,7 @@ const ACCESS_PERMISSIONS_FIELD = 'accessPermissions';
86
185
  const perms = value;
87
186
  const readValid = perms.read !== undefined && isValidAccessRule(perms.read);
88
187
  const writeValid = perms.write !== undefined && isValidAccessRule(perms.write);
89
- if (!readValid && !writeValid) {
188
+ if (!readValid && !writeValid && Object.keys(perms).length > 0) {
90
189
  return null;
91
190
  }
92
191
  const sanitized = {
@@ -139,6 +238,19 @@ const ACCESS_PERMISSIONS_FIELD = 'accessPermissions';
139
238
  droppedReserved
140
239
  };
141
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
+ }
142
254
  /**
143
255
  * Compute the visible document data from the per-field winning values and the
144
256
  * reserved set/delete timestamps. A document is alive when it carries a set
@@ -153,14 +265,7 @@ const ACCESS_PERMISSIONS_FIELD = 'accessPermissions';
153
265
  if (!alive) {
154
266
  return null;
155
267
  }
156
- const data = {};
157
- for (const [path, value] of Object.entries(fieldValues)){
158
- if (path === DELETE_KEY || path === SET_KEY) {
159
- continue;
160
- }
161
- data[path.slice(1)] = value;
162
- }
163
- return data;
268
+ return assembleDocumentData(fieldValues);
164
269
  }
165
270
  /**
166
271
  * A model's provider merge policy, or `null` for an ordinary model. Read
@@ -195,14 +300,7 @@ const ACCESS_PERMISSIONS_FIELD = 'accessPermissions';
195
300
  if (!alive) {
196
301
  return null;
197
302
  }
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;
303
+ return assembleDocumentData(fieldValues);
206
304
  }
207
305
  /**
208
306
  * Pure merge-policy liveness read: given a document's `field_values` cell map
@@ -222,14 +320,28 @@ const ACCESS_PERMISSIONS_FIELD = 'accessPermissions';
222
320
  const alive = s != null && (t == null || s > t);
223
321
  return alive ? {
224
322
  hlc: s,
225
- digest: setCell.digest,
226
- alive: true
323
+ digest: setCell?.digest,
324
+ alive: true,
325
+ observed: setCell?.observed
227
326
  } : {
228
327
  hlc: t,
229
328
  digest: DELETED_STATE_DIGEST,
230
- alive: false
329
+ alive: false,
330
+ observed: setCell?.observed
231
331
  };
232
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
+ }
233
345
  /**
234
346
  * Pure merge-policy liveness read: the winning side's state digest alone —
235
347
  * the live `$setFetch` digest, or `DELETED_STATE_DIGEST` when the delete side
@@ -330,10 +442,15 @@ export async function applyChangeMutation(ctx, mutation) {
330
442
  if (!allowed) {
331
443
  throw new WriteAccessDeniedError(docID);
332
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
+ }
333
449
  }
334
450
  if (ctx.maxDriftMS != null) {
335
451
  validateHLCDrift(mutation.hlc, ctx.maxDriftMS);
336
452
  }
453
+ validateAccessRulePatches(mutation);
337
454
  const modelID = id.model.toString();
338
455
  const [storedHLCs, storedValues, policy] = await Promise.all([
339
456
  ctx.store.getFieldHLCs(docID),
@@ -376,7 +493,10 @@ export async function applyChangeMutation(ctx, mutation) {
376
493
  ...doc,
377
494
  data: assembled
378
495
  };
379
- const allowed = await ctx.postStateGate(postState);
496
+ const allowed = await ctx.postStateGate(postState, {
497
+ previous: doc,
498
+ mutation
499
+ });
380
500
  if (!allowed) {
381
501
  return {
382
502
  document: null
@@ -419,27 +539,55 @@ export async function applyChangeMutation(ctx, mutation) {
419
539
  }
420
540
  }
421
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
+ }
422
578
  // A change field write wins when its HLC beats the field's current HLC and
423
579
  // also strictly beats the set timestamp. Losing the set comparison means a
424
580
  // newer (or concurrent) set already authored the field set, so a refining
425
581
  // change cannot override it.
426
582
  //
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.
583
+ // Access slots were ordered independently above. Provider-local annotations
584
+ // also order independently of the provider group's set frontier.
436
585
  const winningFields = [];
437
586
  const winningPatches = [];
438
587
  for (const [path, patches] of patchesByField){
439
- const field = path.slice(1);
440
588
  const currentHLC = fieldHLCs[path];
441
589
  const beatsField = currentHLC == null || mutation.hlc > currentHLC;
442
- const decoupled = field === ACCESS_PERMISSIONS_FIELD || groupFieldSet != null;
590
+ const decoupled = groupFieldSet != null;
443
591
  const beatsSet = decoupled || s == null || mutation.hlc > s;
444
592
  if (beatsField && beatsSet) {
445
593
  winningFields.push(path);
@@ -447,7 +595,7 @@ export async function applyChangeMutation(ctx, mutation) {
447
595
  }
448
596
  }
449
597
  // No winning field means no state change; the gate is not invoked.
450
- if (winningFields.length === 0) {
598
+ if (winningFields.length === 0 && !defaultChanged && !permissionsChanged) {
451
599
  return {
452
600
  document: doc
453
601
  };
@@ -457,7 +605,7 @@ export async function applyChangeMutation(ctx, mutation) {
457
605
  // a change only refines fields it already authored.
458
606
  const base = {};
459
607
  for (const [path, value] of Object.entries(fieldValues)){
460
- if (path === DELETE_KEY || path === SET_KEY || path === SET_FETCH_KEY || path === DELETE_FETCH_KEY) {
608
+ if (path === DELETE_KEY || path === SET_KEY || path === SET_FETCH_KEY || path === DELETE_FETCH_KEY || path === EFFECTIVE_READ_KEY) {
461
609
  continue;
462
610
  }
463
611
  base[path.slice(1)] = value;
@@ -491,6 +639,7 @@ export async function applyChangeMutation(ctx, mutation) {
491
639
  fieldValues[path] = validated[field];
492
640
  fieldHLCs[path] = mutation.hlc;
493
641
  }
642
+ foldReadRule(fieldValues);
494
643
  // A merge-policy document's liveness and visible data follow the
495
644
  // fetch-order registers, not `$set`/`/` — mirrors `applySetMutation`'s same
496
645
  // choice. The registers themselves are untouched by this change (the group
@@ -505,7 +654,10 @@ export async function applyChangeMutation(ctx, mutation) {
505
654
  ...doc,
506
655
  data: assembled
507
656
  };
508
- const allowed = await ctx.postStateGate(postState);
657
+ const allowed = await ctx.postStateGate(postState, {
658
+ previous: doc,
659
+ mutation
660
+ });
509
661
  if (!allowed) {
510
662
  return {
511
663
  document: null
@@ -570,18 +722,7 @@ export async function applySetMutation(ctx, mutation) {
570
722
  // Reserved fields are engine metadata the model schema never declares. Split
571
723
  // them out before validating the model-declared remainder, then re-attach so
572
724
  // they persist unchanged.
573
- const { model: modelData, reserved } = splitReservedFields(mutation.data);
574
- let data;
575
- try {
576
- data = {
577
- ...asType(validator, modelData),
578
- ...reserved
579
- };
580
- } catch (err) {
581
- throw new MutationSchemaValidationError(docID, {
582
- cause: err
583
- });
584
- }
725
+ const data = validateSetData(validator, mutation.data, docID);
585
726
  const fieldHLCs = {
586
727
  ...storedHLCs ?? {}
587
728
  };
@@ -610,17 +751,26 @@ export async function applySetMutation(ctx, mutation) {
610
751
  if (!allowed) {
611
752
  throw new WriteAccessDeniedError(docID);
612
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
+ }
613
758
  }
614
759
  if (doc != null && doc.owner !== owner) {
615
760
  throw new OwnerChangeError(id.toString(), doc.owner, owner);
616
761
  }
617
- let changed = false;
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
+ }
618
767
  let setChanged = false;
619
768
  let mergeFields = null;
620
769
  if (policy == null) {
621
770
  // Per-field LWW: a set field write wins when its HLC beats the field's
622
771
  // current HLC. Winning writes update both the retained value and timestamp.
623
772
  for (const [field, value] of Object.entries(data)){
773
+ if (field === DEFAULT_ACCESS_PERMISSIONS_FIELD || field === ACCESS_PERMISSIONS_FIELD) continue;
624
774
  const path = `/${field}`;
625
775
  const currentHLC = fieldHLCs[path];
626
776
  if (currentHLC == null || mutation.hlc > currentHLC) {
@@ -639,7 +789,7 @@ export async function applySetMutation(ctx, mutation) {
639
789
  // Local-annotation fields (outside the provider group) keep plain
640
790
  // per-field HLC LWW, exactly like an ordinary model's fields.
641
791
  for (const [field, value] of Object.entries(data)){
642
- if (groupFields.has(field)) {
792
+ if (groupFields.has(field) || field === DEFAULT_ACCESS_PERMISSIONS_FIELD || field === ACCESS_PERMISSIONS_FIELD) {
643
793
  continue;
644
794
  }
645
795
  const path = `/${field}`;
@@ -690,7 +840,10 @@ export async function applySetMutation(ctx, mutation) {
690
840
  }
691
841
  fieldValues[SET_FETCH_KEY] = {
692
842
  hlc: token,
693
- digest: candidateDigest
843
+ digest: candidateDigest,
844
+ ...frontier?.observed !== undefined && token <= frontier.observed.fetchHLC ? {
845
+ observed: frontier.observed
846
+ } : {}
694
847
  };
695
848
  fieldHLCs[SET_FETCH_KEY] = token;
696
849
  setChanged = true;
@@ -709,6 +862,7 @@ export async function applySetMutation(ctx, mutation) {
709
862
  };
710
863
  }
711
864
  }
865
+ foldReadRule(fieldValues);
712
866
  const assembled = policy == null ? assemble(fieldValues, fieldHLCs) : assembleMergePolicy(fieldValues, fieldHLCs);
713
867
  const toResult = (document)=>mergeFields == null ? {
714
868
  document
@@ -727,7 +881,10 @@ export async function applySetMutation(ctx, mutation) {
727
881
  ...synthesizedPostState,
728
882
  data: assembled
729
883
  };
730
- const allowed = await ctx.postStateGate(postState);
884
+ const allowed = await ctx.postStateGate(postState, {
885
+ previous: doc,
886
+ mutation
887
+ });
731
888
  if (!allowed) {
732
889
  return {
733
890
  document: null
@@ -750,7 +907,10 @@ export async function applySetMutation(ctx, mutation) {
750
907
  ...doc,
751
908
  data: assembled
752
909
  };
753
- const allowed = await ctx.postStateGate(postState);
910
+ const allowed = await ctx.postStateGate(postState, {
911
+ previous: doc,
912
+ mutation
913
+ });
754
914
  if (!allowed) {
755
915
  return {
756
916
  document: null
@@ -758,11 +918,12 @@ export async function applySetMutation(ctx, mutation) {
758
918
  }
759
919
  }
760
920
  await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
761
- return toResult(await ctx.store.saveDocument({
921
+ const saved = await ctx.store.saveDocument({
762
922
  id,
763
923
  existing: doc,
764
924
  data: assembled
765
- }));
925
+ });
926
+ return toResult(saved);
766
927
  }
767
928
  /**
768
929
  * A fetch-token-carrying deletion on a merge-policy document's `/fetch`
@@ -862,6 +1023,16 @@ export async function applySetMutation(ctx, mutation) {
862
1023
  fieldValues[DELETE_FETCH_KEY] = {
863
1024
  hlc: token
864
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
+ }
865
1036
  fieldHLCs[DELETE_FETCH_KEY] = token;
866
1037
  // A winning delete always moves the register to the constant deleted
867
1038
  // digest, regardless of what the frontier held before.
@@ -894,7 +1065,10 @@ export async function applySetMutation(ctx, mutation) {
894
1065
  ...doc,
895
1066
  data: assembled
896
1067
  };
897
- const allowed = await ctx.postStateGate(postState);
1068
+ const allowed = await ctx.postStateGate(postState, {
1069
+ previous: doc,
1070
+ mutation
1071
+ });
898
1072
  if (!allowed) {
899
1073
  return {
900
1074
  document: null
package/lib/errors.d.ts CHANGED
@@ -1,11 +1,23 @@
1
+ export type KubunMutationErrorCode = 'KB07' | 'KB09' | 'KB10' | 'KB11' | 'KB12' | 'KB13' | 'KB16' | 'KB17' | 'KB18' | 'KB19' | 'KB20' | 'KB21' | 'KB22';
2
+ export type KubunMutationErrorParams = {
3
+ message: string;
4
+ code: KubunMutationErrorCode;
5
+ docID?: string;
6
+ cause?: unknown;
7
+ };
8
+ export declare class KubunMutationError extends Error {
9
+ #private;
10
+ constructor(params: KubunMutationErrorParams);
11
+ get code(): KubunMutationErrorCode;
12
+ get docID(): string | undefined;
13
+ }
1
14
  /**
2
15
  * Thrown by the mutation apply path when a non-owner writer's capability
3
16
  * check fails. Tagged with the protocol-level error code `KB07` so callers
4
17
  * — particularly the RPC handler — can translate the throw into a
5
18
  * structured wire response without losing the reason.
6
19
  */
7
- export declare class WriteAccessDeniedError extends Error {
8
- #private;
20
+ export declare class WriteAccessDeniedError extends KubunMutationError {
9
21
  constructor(docID: string);
10
22
  get code(): 'KB07';
11
23
  get docID(): string;
@@ -19,8 +31,7 @@ export declare class WriteAccessDeniedError extends Error {
19
31
  * error code `KB09` so callers — particularly the RPC handler — can translate
20
32
  * the throw into a structured wire response without losing the reason.
21
33
  */
22
- export declare class DocumentIDMismatchError extends Error {
23
- #private;
34
+ export declare class DocumentIDMismatchError extends KubunMutationError {
24
35
  constructor(docID: string);
25
36
  get code(): 'KB09';
26
37
  get docID(): string;
@@ -32,7 +43,7 @@ export declare class DocumentIDMismatchError extends Error {
32
43
  * the bound — the reject is permanent. Tagged `KB10` so the RPC handler can
33
44
  * translate the throw into a structured wire response.
34
45
  */
35
- export declare class HLCDriftError extends Error {
46
+ export declare class HLCDriftError extends KubunMutationError {
36
47
  #private;
37
48
  constructor(driftMS: number, maxDriftMS: number);
38
49
  get code(): 'KB10';
@@ -45,7 +56,7 @@ export declare class HLCDriftError extends Error {
45
56
  * `HLC.tryParse` validation. Tagged `KB16` so the RPC handler can translate the
46
57
  * throw into a structured wire response.
47
58
  */
48
- export declare class InvalidFetchTokenError extends Error {
59
+ export declare class InvalidFetchTokenError extends KubunMutationError {
49
60
  #private;
50
61
  constructor(token: string);
51
62
  get code(): 'KB16';
@@ -58,7 +69,7 @@ export declare class InvalidFetchTokenError extends Error {
58
69
  * `KB17` so the RPC handler can translate the throw into a structured wire
59
70
  * response.
60
71
  */
61
- export declare class FetchTokenOrderError extends Error {
72
+ export declare class FetchTokenOrderError extends KubunMutationError {
62
73
  constructor();
63
74
  get code(): 'KB17';
64
75
  }
@@ -70,7 +81,7 @@ export declare class FetchTokenOrderError extends Error {
70
81
  * value instead of throwing). Tagged `KB18` so the RPC handler can translate
71
82
  * the throw into a structured wire response.
72
83
  */
73
- export declare class IncompleteProviderGroupError extends Error {
84
+ export declare class IncompleteProviderGroupError extends KubunMutationError {
74
85
  #private;
75
86
  constructor(docID: string, field: string);
76
87
  get code(): 'KB18';
@@ -86,8 +97,7 @@ export declare class IncompleteProviderGroupError extends Error {
86
97
  * `KB19` so the RPC handler can translate the throw into a structured wire
87
98
  * response.
88
99
  */
89
- export declare class TombstoneRequiresMergePolicyError extends Error {
90
- #private;
100
+ export declare class TombstoneRequiresMergePolicyError extends KubunMutationError {
91
101
  constructor(docID: string);
92
102
  get code(): 'KB19';
93
103
  get docID(): string;
@@ -101,7 +111,7 @@ export declare class TombstoneRequiresMergePolicyError extends Error {
101
111
  * CHANGE to a field outside the group is unaffected. Tagged `KB20` so the RPC
102
112
  * handler can translate the throw into a structured wire response.
103
113
  */
104
- export declare class ProviderGroupChangeRejectedError extends Error {
114
+ export declare class ProviderGroupChangeRejectedError extends KubunMutationError {
105
115
  #private;
106
116
  constructor(docID: string, field: string);
107
117
  get code(): 'KB20';
@@ -118,8 +128,7 @@ export declare class ProviderGroupChangeRejectedError extends Error {
118
128
  * `KB21` so the RPC handler can translate the throw into a structured wire
119
129
  * response.
120
130
  */
121
- export declare class MissingFetchTokenError extends Error {
122
- #private;
131
+ export declare class MissingFetchTokenError extends KubunMutationError {
123
132
  constructor(docID: string);
124
133
  get code(): 'KB21';
125
134
  get docID(): string;
@@ -136,8 +145,7 @@ export declare class MissingFetchTokenError extends Error {
136
145
  * always fails. Tagged `KB22` so the RPC handler can translate the throw into a
137
146
  * structured wire response.
138
147
  */
139
- export declare class MergePolicyRemoveRequiresTombstoneError extends Error {
140
- #private;
148
+ export declare class MergePolicyRemoveRequiresTombstoneError extends KubunMutationError {
141
149
  constructor(docID: string);
142
150
  get code(): 'KB22';
143
151
  get docID(): string;
@@ -149,8 +157,7 @@ export declare class MergePolicyRemoveRequiresTombstoneError extends Error {
149
157
  * Tagged `KB11` so the RPC handler can translate the throw into a structured
150
158
  * wire response.
151
159
  */
152
- export declare class OwnerChangeError extends Error {
153
- #private;
160
+ export declare class OwnerChangeError extends KubunMutationError {
154
161
  constructor(docID: string, fromOwner: string, toOwner: string);
155
162
  get code(): 'KB11';
156
163
  get docID(): string;
@@ -161,7 +168,7 @@ export declare class OwnerChangeError extends Error {
161
168
  * deterministic, permanent reject. Tagged `KB12` so the RPC handler can
162
169
  * translate the throw into a structured wire response.
163
170
  */
164
- export declare class UnsupportedMutationTypeError extends Error {
171
+ export declare class UnsupportedMutationTypeError extends KubunMutationError {
165
172
  #private;
166
173
  constructor(mutationType: string);
167
174
  get code(): 'KB12';
@@ -174,8 +181,7 @@ export declare class UnsupportedMutationTypeError extends Error {
174
181
  * preserved as `cause`. Tagged `KB13` so the RPC handler can translate the
175
182
  * throw into a structured wire response.
176
183
  */
177
- export declare class MutationSchemaValidationError extends Error {
178
- #private;
184
+ export declare class MutationSchemaValidationError extends KubunMutationError {
179
185
  constructor(docID: string, options?: {
180
186
  cause?: unknown;
181
187
  });
@@ -193,6 +199,15 @@ export declare class MutationSchemaValidationError extends Error {
193
199
  export declare class InvalidAccessPermissionsError extends Error {
194
200
  constructor(detail?: string);
195
201
  }
202
+ export declare class InvalidDefaultAccessPermissionsError extends KubunMutationError {
203
+ constructor(docID: string);
204
+ }
205
+ type InvalidAccessRulePatchErrorParams = {
206
+ docID: string;
207
+ };
208
+ export declare class InvalidAccessRulePatchError extends KubunMutationError {
209
+ constructor(params: InvalidAccessRulePatchErrorParams);
210
+ }
196
211
  /**
197
212
  * True when `err` carries a protocol error code identifying a deterministically
198
213
  * permanent mutation reject. A permanent reject is one the sender cannot fix by
@@ -201,3 +216,4 @@ export declare class InvalidAccessPermissionsError extends Error {
201
216
  * else — a transient store/db failure — is excluded so it stays retryable.
202
217
  */
203
218
  export declare function isPermanentApplyError(err: unknown): boolean;
219
+ export {};
package/lib/errors.js CHANGED
@@ -1,20 +1,40 @@
1
+ export class KubunMutationError extends Error {
2
+ #code;
3
+ #docID;
4
+ constructor(params){
5
+ super(params.message, Object.hasOwn(params, 'cause') ? {
6
+ cause: params.cause
7
+ } : undefined);
8
+ this.name = 'KubunMutationError';
9
+ this.#code = params.code;
10
+ this.#docID = params.docID;
11
+ }
12
+ get code() {
13
+ return this.#code;
14
+ }
15
+ get docID() {
16
+ return this.#docID;
17
+ }
18
+ }
1
19
  /**
2
20
  * Thrown by the mutation apply path when a non-owner writer's capability
3
21
  * check fails. Tagged with the protocol-level error code `KB07` so callers
4
22
  * — particularly the RPC handler — can translate the throw into a
5
23
  * structured wire response without losing the reason.
6
- */ export class WriteAccessDeniedError extends Error {
7
- #docID;
24
+ */ export class WriteAccessDeniedError extends KubunMutationError {
8
25
  constructor(docID){
9
- super(`Access denied: cannot write document ${docID}`);
26
+ super({
27
+ message: `Access denied: cannot write document ${docID}`,
28
+ code: 'KB07',
29
+ docID
30
+ });
10
31
  this.name = 'WriteAccessDeniedError';
11
- this.#docID = docID;
12
32
  }
13
33
  get code() {
14
34
  return 'KB07';
15
35
  }
16
36
  get docID() {
17
- return this.#docID;
37
+ return super.docID;
18
38
  }
19
39
  }
20
40
  /**
@@ -25,18 +45,20 @@
25
45
  * a victim's deterministic-unique document ID. Tagged with the protocol-level
26
46
  * error code `KB09` so callers — particularly the RPC handler — can translate
27
47
  * the throw into a structured wire response without losing the reason.
28
- */ export class DocumentIDMismatchError extends Error {
29
- #docID;
48
+ */ export class DocumentIDMismatchError extends KubunMutationError {
30
49
  constructor(docID){
31
- super(`Document ID mismatch: ${docID} is not bound to its owner and unique value`);
50
+ super({
51
+ message: `Document ID mismatch: ${docID} is not bound to its owner and unique value`,
52
+ code: 'KB09',
53
+ docID
54
+ });
32
55
  this.name = 'DocumentIDMismatchError';
33
- this.#docID = docID;
34
56
  }
35
57
  get code() {
36
58
  return 'KB09';
37
59
  }
38
60
  get docID() {
39
- return this.#docID;
61
+ return super.docID;
40
62
  }
41
63
  }
42
64
  /**
@@ -45,11 +67,14 @@
45
67
  * is a fixed property of the mutation, so resending cannot move it back under
46
68
  * the bound — the reject is permanent. Tagged `KB10` so the RPC handler can
47
69
  * translate the throw into a structured wire response.
48
- */ export class HLCDriftError extends Error {
70
+ */ export class HLCDriftError extends KubunMutationError {
49
71
  #driftMS;
50
72
  #maxDriftMS;
51
73
  constructor(driftMS, maxDriftMS){
52
- super(`HLC timestamp too far in the future: ${driftMS}ms drift exceeds ${maxDriftMS}ms limit`);
74
+ super({
75
+ message: `HLC timestamp too far in the future: ${driftMS}ms drift exceeds ${maxDriftMS}ms limit`,
76
+ code: 'KB10'
77
+ });
53
78
  this.name = 'HLCDriftError';
54
79
  this.#driftMS = driftMS;
55
80
  this.#maxDriftMS = maxDriftMS;
@@ -69,10 +94,13 @@
69
94
  * malformed tokens or NaN values that would slip past `HLC.parse` but fail
70
95
  * `HLC.tryParse` validation. Tagged `KB16` so the RPC handler can translate the
71
96
  * throw into a structured wire response.
72
- */ export class InvalidFetchTokenError extends Error {
97
+ */ export class InvalidFetchTokenError extends KubunMutationError {
73
98
  #token;
74
99
  constructor(token){
75
- super(`Invalid fetch token: malformed HLC timestamp '${token}'`);
100
+ super({
101
+ message: `Invalid fetch token: malformed HLC timestamp '${token}'`,
102
+ code: 'KB16'
103
+ });
76
104
  this.name = 'InvalidFetchTokenError';
77
105
  this.#token = token;
78
106
  }
@@ -89,9 +117,12 @@
89
117
  * this reject is permanent — resending cannot move the HLC forward. Tagged
90
118
  * `KB17` so the RPC handler can translate the throw into a structured wire
91
119
  * response.
92
- */ export class FetchTokenOrderError extends Error {
120
+ */ export class FetchTokenOrderError extends KubunMutationError {
93
121
  constructor(){
94
- super('Fetch token ordering violated: mutation HLC must be >= sourceFetchHLC');
122
+ super({
123
+ message: 'Fetch token ordering violated: mutation HLC must be >= sourceFetchHLC',
124
+ code: 'KB17'
125
+ });
95
126
  this.name = 'FetchTokenOrderError';
96
127
  }
97
128
  get code() {
@@ -105,20 +136,22 @@
105
136
  * omitted OPTIONAL group field is legitimate — it clears that field's stored
106
137
  * value instead of throwing). Tagged `KB18` so the RPC handler can translate
107
138
  * the throw into a structured wire response.
108
- */ export class IncompleteProviderGroupError extends Error {
109
- #docID;
139
+ */ export class IncompleteProviderGroupError extends KubunMutationError {
110
140
  #field;
111
141
  constructor(docID, field){
112
- super(`Incomplete provider group: document ${docID} SET omits required field '${field}'`);
142
+ super({
143
+ message: `Incomplete provider group: document ${docID} SET omits required field '${field}'`,
144
+ code: 'KB18',
145
+ docID
146
+ });
113
147
  this.name = 'IncompleteProviderGroupError';
114
- this.#docID = docID;
115
148
  this.#field = field;
116
149
  }
117
150
  get code() {
118
151
  return 'KB18';
119
152
  }
120
153
  get docID() {
121
- return this.#docID;
154
+ return super.docID;
122
155
  }
123
156
  get field() {
124
157
  return this.#field;
@@ -132,18 +165,20 @@
132
165
  * HLC registers and deletes via the `/`→null CHANGE path instead. Tagged
133
166
  * `KB19` so the RPC handler can translate the throw into a structured wire
134
167
  * response.
135
- */ export class TombstoneRequiresMergePolicyError extends Error {
136
- #docID;
168
+ */ export class TombstoneRequiresMergePolicyError extends KubunMutationError {
137
169
  constructor(docID){
138
- super(`Tombstone requires a provider-merge-policy model: document ${docID}`);
170
+ super({
171
+ message: `Tombstone requires a provider-merge-policy model: document ${docID}`,
172
+ code: 'KB19',
173
+ docID
174
+ });
139
175
  this.name = 'TombstoneRequiresMergePolicyError';
140
- this.#docID = docID;
141
176
  }
142
177
  get code() {
143
178
  return 'KB19';
144
179
  }
145
180
  get docID() {
146
- return this.#docID;
181
+ return super.docID;
147
182
  }
148
183
  }
149
184
  /**
@@ -154,20 +189,22 @@
154
189
  * against it would mix two incompatible orderings on the same fields. A
155
190
  * CHANGE to a field outside the group is unaffected. Tagged `KB20` so the RPC
156
191
  * handler can translate the throw into a structured wire response.
157
- */ export class ProviderGroupChangeRejectedError extends Error {
158
- #docID;
192
+ */ export class ProviderGroupChangeRejectedError extends KubunMutationError {
159
193
  #field;
160
194
  constructor(docID, field){
161
- super(`Provider group CHANGE rejected: document ${docID} CHANGE targets provider group field '${field}'`);
195
+ super({
196
+ message: `Provider group CHANGE rejected: document ${docID} CHANGE targets provider group field '${field}'`,
197
+ code: 'KB20',
198
+ docID
199
+ });
162
200
  this.name = 'ProviderGroupChangeRejectedError';
163
- this.#docID = docID;
164
201
  this.#field = field;
165
202
  }
166
203
  get code() {
167
204
  return 'KB20';
168
205
  }
169
206
  get docID() {
170
- return this.#docID;
207
+ return super.docID;
171
208
  }
172
209
  get field() {
173
210
  return this.#field;
@@ -182,18 +219,20 @@
182
219
  * present by the time a merge-policy candidate reaches classification. Tagged
183
220
  * `KB21` so the RPC handler can translate the throw into a structured wire
184
221
  * response.
185
- */ export class MissingFetchTokenError extends Error {
186
- #docID;
222
+ */ export class MissingFetchTokenError extends KubunMutationError {
187
223
  constructor(docID){
188
- super(`Missing fetch token: document ${docID} merge-policy write carries no sourceFetchHLC`);
224
+ super({
225
+ message: `Missing fetch token: document ${docID} merge-policy write carries no sourceFetchHLC`,
226
+ code: 'KB21',
227
+ docID
228
+ });
189
229
  this.name = 'MissingFetchTokenError';
190
- this.#docID = docID;
191
230
  }
192
231
  get code() {
193
232
  return 'KB21';
194
233
  }
195
234
  get docID() {
196
- return this.#docID;
235
+ return super.docID;
197
236
  }
198
237
  }
199
238
  /**
@@ -207,18 +246,20 @@
207
246
  * `/fetch`. The rejection is deterministic and permanent — the same mutation
208
247
  * always fails. Tagged `KB22` so the RPC handler can translate the throw into a
209
248
  * structured wire response.
210
- */ export class MergePolicyRemoveRequiresTombstoneError extends Error {
211
- #docID;
249
+ */ export class MergePolicyRemoveRequiresTombstoneError extends KubunMutationError {
212
250
  constructor(docID){
213
- super(`Merge-policy remove requires a tombstone: document ${docID} must be deleted via the fetch-token tombstone path`);
251
+ super({
252
+ message: `Merge-policy remove requires a tombstone: document ${docID} must be deleted via the fetch-token tombstone path`,
253
+ code: 'KB22',
254
+ docID
255
+ });
214
256
  this.name = 'MergePolicyRemoveRequiresTombstoneError';
215
- this.#docID = docID;
216
257
  }
217
258
  get code() {
218
259
  return 'KB22';
219
260
  }
220
261
  get docID() {
221
- return this.#docID;
262
+ return super.docID;
222
263
  }
223
264
  }
224
265
  /**
@@ -227,18 +268,20 @@
227
268
  * deterministic, permanent reject — the same mutation will always fail.
228
269
  * Tagged `KB11` so the RPC handler can translate the throw into a structured
229
270
  * wire response.
230
- */ export class OwnerChangeError extends Error {
231
- #docID;
271
+ */ export class OwnerChangeError extends KubunMutationError {
232
272
  constructor(docID, fromOwner, toOwner){
233
- super(`Cannot change owner from ${fromOwner} to ${toOwner} in document: ${docID}`);
273
+ super({
274
+ message: `Cannot change owner from ${fromOwner} to ${toOwner} in document: ${docID}`,
275
+ code: 'KB11',
276
+ docID
277
+ });
234
278
  this.name = 'OwnerChangeError';
235
- this.#docID = docID;
236
279
  }
237
280
  get code() {
238
281
  return 'KB11';
239
282
  }
240
283
  get docID() {
241
- return this.#docID;
284
+ return super.docID;
242
285
  }
243
286
  }
244
287
  /**
@@ -246,10 +289,13 @@
246
289
  * not support. The type is fixed in the signed mutation, so this is a
247
290
  * deterministic, permanent reject. Tagged `KB12` so the RPC handler can
248
291
  * translate the throw into a structured wire response.
249
- */ export class UnsupportedMutationTypeError extends Error {
292
+ */ export class UnsupportedMutationTypeError extends KubunMutationError {
250
293
  #mutationType;
251
294
  constructor(mutationType){
252
- super(`Unsupported mutation type: ${mutationType}`);
295
+ super({
296
+ message: `Unsupported mutation type: ${mutationType}`,
297
+ code: 'KB12'
298
+ });
253
299
  this.name = 'UnsupportedMutationTypeError';
254
300
  this.#mutationType = mutationType;
255
301
  }
@@ -266,18 +312,21 @@
266
312
  * is a deterministic, permanent reject. The original validation error is
267
313
  * preserved as `cause`. Tagged `KB13` so the RPC handler can translate the
268
314
  * throw into a structured wire response.
269
- */ export class MutationSchemaValidationError extends Error {
270
- #docID;
315
+ */ export class MutationSchemaValidationError extends KubunMutationError {
271
316
  constructor(docID, options){
272
- super(`Mutation payload failed model schema validation for document ${docID}`, options);
317
+ super({
318
+ message: `Mutation payload failed model schema validation for document ${docID}`,
319
+ code: 'KB13',
320
+ docID,
321
+ ...options
322
+ });
273
323
  this.name = 'MutationSchemaValidationError';
274
- this.#docID = docID;
275
324
  }
276
325
  get code() {
277
326
  return 'KB13';
278
327
  }
279
328
  get docID() {
280
- return this.#docID;
329
+ return super.docID;
281
330
  }
282
331
  }
283
332
  /**
@@ -293,6 +342,26 @@
293
342
  this.name = 'InvalidAccessPermissionsError';
294
343
  }
295
344
  }
345
+ export class InvalidDefaultAccessPermissionsError extends KubunMutationError {
346
+ constructor(docID){
347
+ super({
348
+ message: 'Invalid default access rule or HLC: expected a rule and HLC no later than the mutation',
349
+ code: 'KB13',
350
+ docID
351
+ });
352
+ this.name = 'InvalidDefaultAccessPermissionsError';
353
+ }
354
+ }
355
+ export class InvalidAccessRulePatchError extends KubunMutationError {
356
+ constructor(params){
357
+ super({
358
+ message: 'Invalid access rule patch: replace or remove a whole slot or read/write rule',
359
+ code: 'KB13',
360
+ docID: params.docID
361
+ });
362
+ this.name = 'InvalidAccessRulePatchError';
363
+ }
364
+ }
296
365
  const PERMANENT_APPLY_ERROR_CODES = new Set([
297
366
  'KB07',
298
367
  'KB09',
package/lib/index.d.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  export { HLC, type HLCTimestamp } from '@kubun/hlc';
2
- export type { DocumentValidator, FetchFrontier, MergeOutcome, MutationApplyResult, MutationContext, MutationDB, PostStateGate, ValidatorsRecord, } from './apply.js';
3
- export { applyChangeMutation, applyMutation, applySetMutation, applyTombstoneMutation, classifySetOutcome, classifyTombstoneOutcome, DEFAULT_MAX_DRIFT_MS, DELETE_FETCH_KEY, getTopLevelField, resolveModelPolicy, SET_FETCH_KEY, validateFetchTokenDrift, winnerStateDigestOf, } from './apply.js';
2
+ export { isAccessSlotOnlyMutation } from './access-slot-only.js';
3
+ export type { DocumentValidator, FetchFrontier, MergeOutcome, MutationApplyResult, MutationContext, MutationDB, PostStateGate, PostStateGateContext, ValidatorsRecord, } from './apply.js';
4
+ export { applyChangeMutation, applyMutation, applySetMutation, applyTombstoneMutation, assembleDocumentData, classifySetOutcome, classifyTombstoneOutcome, DEFAULT_MAX_DRIFT_MS, DELETE_FETCH_KEY, getTopLevelField, providerGroupDigest, resolveModelPolicy, SET_FETCH_KEY, validateFetchTokenDrift, validateSetData, winnerStateDigestOf, } from './apply.js';
4
5
  export type { CreateChangeMutationParams, CreateRemoveMutationParams, CreateSetMutationParams, CreateTombstoneMutationParams, } from './create.js';
5
6
  export { createChangeMutation, createRemoveMutation, createSetMutation, createTombstoneMutation, } from './create.js';
6
- export { DocumentIDMismatchError, FetchTokenOrderError, HLCDriftError, IncompleteProviderGroupError, InvalidAccessPermissionsError, InvalidFetchTokenError, isPermanentApplyError, MergePolicyRemoveRequiresTombstoneError, MissingFetchTokenError, MutationSchemaValidationError, OwnerChangeError, ProviderGroupChangeRejectedError, TombstoneRequiresMergePolicyError, UnsupportedMutationTypeError, WriteAccessDeniedError, } from './errors.js';
7
+ export type { KubunMutationErrorCode, KubunMutationErrorParams } from './errors.js';
8
+ export { DocumentIDMismatchError, FetchTokenOrderError, HLCDriftError, IncompleteProviderGroupError, InvalidAccessPermissionsError, InvalidAccessRulePatchError, InvalidDefaultAccessPermissionsError, InvalidFetchTokenError, isPermanentApplyError, KubunMutationError, MergePolicyRemoveRequiresTombstoneError, MissingFetchTokenError, MutationSchemaValidationError, OwnerChangeError, ProviderGroupChangeRejectedError, TombstoneRequiresMergePolicyError, UnsupportedMutationTypeError, WriteAccessDeniedError, } from './errors.js';
9
+ export { type HeldProviderMutation, type HeldProviderWinner, type SelectHeldProviderWinnerParams, selectHeldProviderWinner, } from './observation-winner.js';
7
10
  export type { CreateDocumentParams, CreateMutationOperationsParams, MutationOperations, RemoveDocumentParams, SetDocumentParams, UpdateDocumentParams, } from './operations.js';
8
11
  export { convertPatchInput, createMutationOperations } from './operations.js';
9
12
  export { DELETED_STATE_DIGEST, type StateDigestInput, stateDigest } from './state-digest.js';
package/lib/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { HLC } from '@kubun/hlc';
2
- export { applyChangeMutation, applyMutation, applySetMutation, applyTombstoneMutation, classifySetOutcome, classifyTombstoneOutcome, DEFAULT_MAX_DRIFT_MS, DELETE_FETCH_KEY, getTopLevelField, resolveModelPolicy, SET_FETCH_KEY, validateFetchTokenDrift, winnerStateDigestOf } from './apply.js';
2
+ export { isAccessSlotOnlyMutation } from './access-slot-only.js';
3
+ export { applyChangeMutation, applyMutation, applySetMutation, applyTombstoneMutation, assembleDocumentData, classifySetOutcome, classifyTombstoneOutcome, DEFAULT_MAX_DRIFT_MS, DELETE_FETCH_KEY, getTopLevelField, providerGroupDigest, resolveModelPolicy, SET_FETCH_KEY, validateFetchTokenDrift, validateSetData, winnerStateDigestOf } from './apply.js';
3
4
  export { createChangeMutation, createRemoveMutation, createSetMutation, createTombstoneMutation } from './create.js';
4
- export { DocumentIDMismatchError, FetchTokenOrderError, HLCDriftError, IncompleteProviderGroupError, InvalidAccessPermissionsError, InvalidFetchTokenError, isPermanentApplyError, MergePolicyRemoveRequiresTombstoneError, MissingFetchTokenError, MutationSchemaValidationError, OwnerChangeError, ProviderGroupChangeRejectedError, TombstoneRequiresMergePolicyError, UnsupportedMutationTypeError, WriteAccessDeniedError } from './errors.js';
5
+ export { DocumentIDMismatchError, FetchTokenOrderError, HLCDriftError, IncompleteProviderGroupError, InvalidAccessPermissionsError, InvalidAccessRulePatchError, InvalidDefaultAccessPermissionsError, InvalidFetchTokenError, isPermanentApplyError, KubunMutationError, MergePolicyRemoveRequiresTombstoneError, MissingFetchTokenError, MutationSchemaValidationError, OwnerChangeError, ProviderGroupChangeRejectedError, TombstoneRequiresMergePolicyError, UnsupportedMutationTypeError, WriteAccessDeniedError } from './errors.js';
6
+ export { selectHeldProviderWinner } from './observation-winner.js';
5
7
  export { convertPatchInput, createMutationOperations } from './operations.js';
6
8
  export { DELETED_STATE_DIGEST, stateDigest } from './state-digest.js';
@@ -0,0 +1,19 @@
1
+ import type { SetDocumentMutation, TombstoneDocumentMutation } from '@kubun/protocol';
2
+ import { type DocumentValidator, type ObservedProviderValue } from './apply.js';
3
+ export type HeldProviderMutation = (SetDocumentMutation | TombstoneDocumentMutation) & {
4
+ sourceFetchHLC: string;
5
+ };
6
+ export type HeldProviderWinner = {
7
+ mutation: HeldProviderMutation;
8
+ token: string;
9
+ digest: string;
10
+ present: boolean;
11
+ group: Record<string, unknown>;
12
+ };
13
+ export type SelectHeldProviderWinnerParams = {
14
+ mutations: ReadonlyArray<HeldProviderMutation>;
15
+ observed: ObservedProviderValue;
16
+ fields: ReadonlyArray<string>;
17
+ validator: DocumentValidator;
18
+ };
19
+ export declare function selectHeldProviderWinner(params: SelectHeldProviderWinnerParams): HeldProviderWinner | null;
@@ -0,0 +1,48 @@
1
+ import { providerGroupDigest, validateSetData } from './apply.js';
2
+ import { DELETED_STATE_DIGEST, stateDigest } from './state-digest.js';
3
+ function candidate(mutation, fields, validator) {
4
+ const token = mutation.sourceFetchHLC;
5
+ if (mutation.typ === 'tombstone') {
6
+ return {
7
+ mutation,
8
+ token,
9
+ digest: DELETED_STATE_DIGEST,
10
+ present: false,
11
+ group: {}
12
+ };
13
+ }
14
+ const group = {};
15
+ const data = validateSetData(validator, mutation.data, mutation.sub);
16
+ for (const field of fields){
17
+ if (field in data) group[field] = data[field];
18
+ }
19
+ return {
20
+ mutation,
21
+ token,
22
+ digest: stateDigest({
23
+ state: 'live',
24
+ group
25
+ }),
26
+ present: true,
27
+ group
28
+ };
29
+ }
30
+ function comparesAfter(left, right) {
31
+ if (left.token !== right.token) return left.token > right.token;
32
+ if (left.present !== right.present) return !left.present;
33
+ return left.digest > right.digest;
34
+ }
35
+ export function selectHeldProviderWinner(params) {
36
+ const { mutations, observed, fields, validator } = params;
37
+ let base = null;
38
+ let qualifying = null;
39
+ for (const mutation of mutations){
40
+ const current = candidate(mutation, fields, validator);
41
+ if (base === null || comparesAfter(current, base)) base = current;
42
+ const matches = current.present === observed.present && (current.present ? providerGroupDigest(current.group) === observed.valueDigest : observed.valueDigest === null);
43
+ if (current.token >= observed.fetchHLC || matches) {
44
+ if (qualifying === null || comparesAfter(current, qualifying)) qualifying = current;
45
+ }
46
+ }
47
+ return qualifying ?? base;
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/mutation",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,15 +15,15 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/hlc": "^0.17.0",
19
- "@kubun/id": "^0.17.0",
20
- "@sozai/codec": "^0.4.0",
18
+ "@kubun/hlc": "^0.18.0",
19
+ "@kubun/id": "^0.18.0",
20
+ "@sozai/codec": "^0.4.3",
21
21
  "@sozai/patch": "^0.1.3",
22
22
  "@sozai/runtime": "^0.1.0",
23
- "@sozai/schema": "^0.1.2"
23
+ "@sozai/schema": "^0.1.4"
24
24
  },
25
25
  "devDependencies": {
26
- "@kubun/protocol": "^0.17.1"
26
+ "@kubun/protocol": "^0.18.0"
27
27
  },
28
28
  "publishConfig": {
29
29
  "access": "public"