@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/access-slot-only.d.ts +3 -0
- package/lib/access-slot-only.js +23 -0
- package/lib/apply.d.ts +156 -6
- package/lib/apply.js +740 -72
- package/lib/create.d.ts +12 -2
- package/lib/create.js +29 -2
- package/lib/errors.d.ts +129 -10
- package/lib/errors.js +258 -25
- package/lib/index.d.ts +9 -5
- package/lib/index.js +6 -3
- package/lib/observation-winner.d.ts +19 -0
- package/lib/observation-winner.js +48 -0
- package/lib/operations.d.ts +15 -2
- package/lib/operations.js +21 -4
- package/lib/state-digest.d.ts +16 -0
- package/lib/state-digest.js +21 -0
- package/package.json +6 -7
package/lib/apply.js
CHANGED
|
@@ -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
|
|
37
|
-
const ts = HLC.
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
function assertTokenDrift(token, maxDriftMS) {
|
|
52
|
+
const ts = HLC.tryParse(token);
|
|
53
|
+
if (ts == null) {
|
|
54
|
+
throw new InvalidFetchTokenError(token);
|
|
55
|
+
}
|
|
56
|
+
const drift = ts.wallTime - Date.now();
|
|
40
57
|
if (drift > maxDriftMS) {
|
|
41
58
|
throw new HLCDriftError(drift, maxDriftMS);
|
|
42
59
|
}
|
|
43
60
|
}
|
|
61
|
+
function validateHLCDrift(hlcString, maxDriftMS) {
|
|
62
|
+
assertTokenDrift(hlcString, maxDriftMS);
|
|
63
|
+
}
|
|
64
|
+
export function validateFetchTokenDrift(token, maxDriftMS) {
|
|
65
|
+
assertTokenDrift(token, maxDriftMS);
|
|
66
|
+
}
|
|
44
67
|
export function getTopLevelField(path) {
|
|
45
68
|
const secondSlash = path.indexOf('/', 1);
|
|
46
69
|
return secondSlash === -1 ? path : path.slice(0, secondSlash);
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
501
|
+
return {
|
|
502
|
+
document: null
|
|
503
|
+
};
|
|
193
504
|
}
|
|
194
505
|
}
|
|
195
506
|
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
196
|
-
return
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
662
|
+
return {
|
|
663
|
+
document: null
|
|
664
|
+
};
|
|
286
665
|
}
|
|
287
666
|
}
|
|
288
667
|
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
289
|
-
return
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
|
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
|
|
915
|
+
return {
|
|
916
|
+
document: null
|
|
917
|
+
};
|
|
416
918
|
}
|
|
417
919
|
}
|
|
418
920
|
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
419
|
-
|
|
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
|
}
|