@kubun/mutation 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/apply.d.ts +3 -2
- package/lib/apply.js +333 -1
- package/lib/create.js +50 -1
- package/lib/errors.d.ts +76 -0
- package/lib/errors.js +136 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +5 -1
- package/lib/operations.d.ts +12 -1
- package/lib/operations.js +61 -1
- package/package.json +10 -10
package/lib/apply.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { type Validator } from '@enkaku/schema';
|
|
2
1
|
import { HLC } from '@kubun/hlc';
|
|
3
2
|
import type { DocumentModelID } from '@kubun/id';
|
|
4
3
|
import { DocumentID } from '@kubun/id';
|
|
5
4
|
import type { ChangeDocumentMutation, DocumentData, DocumentModel, DocumentMutation, DocumentNode, SetDocumentMutation } from '@kubun/protocol';
|
|
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
|
/**
|
|
@@ -33,7 +33,8 @@ export type MutationDB = {
|
|
|
33
33
|
data: DocumentData | null;
|
|
34
34
|
}): Promise<DocumentNode>;
|
|
35
35
|
getFieldHLCs(documentID: string): Promise<Record<string, string> | null>;
|
|
36
|
-
|
|
36
|
+
getFieldValues(documentID: string): Promise<Record<string, unknown> | null>;
|
|
37
|
+
updateFieldHLCs(docID: DocumentID, fieldHLCs: Record<string, string>, fieldValues: Record<string, unknown>): Promise<void>;
|
|
37
38
|
};
|
|
38
39
|
export type MutationContext = {
|
|
39
40
|
store: MutationDB;
|
package/lib/apply.js
CHANGED
|
@@ -1 +1,333 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { HLC } from '@kubun/hlc';
|
|
2
|
+
import { DocumentID } from '@kubun/id';
|
|
3
|
+
import { fromB64 } from '@sozai/codec';
|
|
4
|
+
import { applyPatches } from '@sozai/patch';
|
|
5
|
+
import { asType, createValidator } from '@sozai/schema';
|
|
6
|
+
import { DocumentIDMismatchError, HLCDriftError, MutationSchemaValidationError, OwnerChangeError, UnsupportedMutationTypeError, WriteAccessDeniedError } from './errors.js';
|
|
7
|
+
/**
|
|
8
|
+
* Reserved `field_hlcs` key holding the greatest delete HLC observed for the
|
|
9
|
+
* document (the tombstone timestamp `t`).
|
|
10
|
+
*/ const DELETE_KEY = '/';
|
|
11
|
+
/**
|
|
12
|
+
* Reserved `field_hlcs` key holding the greatest set HLC observed for the
|
|
13
|
+
* document (the set timestamp `s`).
|
|
14
|
+
*/ const SET_KEY = '$set';
|
|
15
|
+
export function getDocumentValidator(ctx, id) {
|
|
16
|
+
if (ctx.validators[id] == null) {
|
|
17
|
+
ctx.validators[id] = ctx.store.getDocumentModel(id).then((model)=>createValidator({
|
|
18
|
+
...model.schema,
|
|
19
|
+
$id: id
|
|
20
|
+
})).catch((err)=>{
|
|
21
|
+
delete ctx.validators[id];
|
|
22
|
+
throw err;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return ctx.validators[id];
|
|
26
|
+
}
|
|
27
|
+
function validateHLCDrift(hlcString, maxDriftMS) {
|
|
28
|
+
const ts = HLC.parse(hlcString);
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const drift = ts.wallTime - now;
|
|
31
|
+
if (drift > maxDriftMS) {
|
|
32
|
+
throw new HLCDriftError(drift, maxDriftMS);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function getTopLevelField(path) {
|
|
36
|
+
const secondSlash = path.indexOf('/', 1);
|
|
37
|
+
return secondSlash === -1 ? path : path.slice(0, secondSlash);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Compute the visible document data from the per-field winning values and the
|
|
41
|
+
* reserved set/delete timestamps. A document is alive when it carries a set
|
|
42
|
+
* timestamp that is strictly greater than any tombstone timestamp; only then
|
|
43
|
+
* does it surface data. The visible object is assembled solely from the
|
|
44
|
+
* retained field values (keyed by `/field` paths), so the result is a pure
|
|
45
|
+
* function of the accumulated state regardless of the order mutations arrived.
|
|
46
|
+
*/ function assemble(fieldValues, fieldHLCs) {
|
|
47
|
+
const s = fieldHLCs[SET_KEY];
|
|
48
|
+
const t = fieldHLCs[DELETE_KEY];
|
|
49
|
+
const alive = s != null && (t == null || s > t);
|
|
50
|
+
if (!alive) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const data = {};
|
|
54
|
+
for (const [path, value] of Object.entries(fieldValues)){
|
|
55
|
+
if (path === DELETE_KEY || path === SET_KEY) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
data[path.slice(1)] = value;
|
|
59
|
+
}
|
|
60
|
+
return data;
|
|
61
|
+
}
|
|
62
|
+
export async function applyChangeMutation(ctx, mutation) {
|
|
63
|
+
const id = DocumentID.fromString(mutation.sub);
|
|
64
|
+
const docID = id.toString();
|
|
65
|
+
const doc = await ctx.store.getDocument(id);
|
|
66
|
+
if (doc == null) {
|
|
67
|
+
throw new Error(`Document not found: ${docID}`);
|
|
68
|
+
}
|
|
69
|
+
// A non-owner writer must present a valid write capability. Absent an
|
|
70
|
+
// injected checker, only the owner may write (owner-only safe default).
|
|
71
|
+
if (mutation.iss !== doc.owner) {
|
|
72
|
+
const allowed = ctx.checkWriteAccess != null && await ctx.checkWriteAccess(doc, mutation);
|
|
73
|
+
if (!allowed) {
|
|
74
|
+
throw new WriteAccessDeniedError(docID);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (ctx.maxDriftMS != null) {
|
|
78
|
+
validateHLCDrift(mutation.hlc, ctx.maxDriftMS);
|
|
79
|
+
}
|
|
80
|
+
const modelID = id.model.toString();
|
|
81
|
+
const [storedHLCs, storedValues] = await Promise.all([
|
|
82
|
+
ctx.store.getFieldHLCs(docID),
|
|
83
|
+
ctx.store.getFieldValues(docID)
|
|
84
|
+
]);
|
|
85
|
+
const fieldHLCs = {
|
|
86
|
+
...storedHLCs ?? {}
|
|
87
|
+
};
|
|
88
|
+
const fieldValues = {
|
|
89
|
+
...storedValues ?? {}
|
|
90
|
+
};
|
|
91
|
+
const s = fieldHLCs[SET_KEY];
|
|
92
|
+
const t = fieldHLCs[DELETE_KEY];
|
|
93
|
+
// A delete is a single root replace to null. It only advances the tombstone
|
|
94
|
+
// timestamp; it never erases retained field values or field timestamps, so a
|
|
95
|
+
// later set above the tombstone can revive the exact prior field set.
|
|
96
|
+
const isDelete = mutation.patch.length === 1 && mutation.patch[0].op === 'replace' && mutation.patch[0].path === DELETE_KEY && mutation.patch[0].value === null;
|
|
97
|
+
if (isDelete) {
|
|
98
|
+
fieldHLCs[DELETE_KEY] = t == null || mutation.hlc > t ? mutation.hlc : t;
|
|
99
|
+
const assembled = assemble(fieldValues, fieldHLCs);
|
|
100
|
+
// The gate guards state-changing applies. A delete can only move a live
|
|
101
|
+
// document to dead (it never revives one and never alters field values), so
|
|
102
|
+
// visible state changes exactly when a previously-live document becomes
|
|
103
|
+
// tombstoned. Any other delete is a no-op for visible state.
|
|
104
|
+
if (doc.data != null && assembled == null && ctx.postStateGate != null) {
|
|
105
|
+
const postState = {
|
|
106
|
+
...doc,
|
|
107
|
+
data: assembled
|
|
108
|
+
};
|
|
109
|
+
const allowed = await ctx.postStateGate(postState);
|
|
110
|
+
if (!allowed) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
115
|
+
return await ctx.store.saveDocument({
|
|
116
|
+
id,
|
|
117
|
+
existing: doc,
|
|
118
|
+
data: assembled
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const validator = await getDocumentValidator(ctx, modelID);
|
|
122
|
+
// Group patches by top-level field for LWW comparison.
|
|
123
|
+
const patchesByField = new Map();
|
|
124
|
+
for (const patch of mutation.patch){
|
|
125
|
+
const field = getTopLevelField(patch.path);
|
|
126
|
+
const group = patchesByField.get(field);
|
|
127
|
+
if (group != null) {
|
|
128
|
+
group.push(patch);
|
|
129
|
+
} else {
|
|
130
|
+
patchesByField.set(field, [
|
|
131
|
+
patch
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// A change field write wins when its HLC beats the field's current HLC and
|
|
136
|
+
// also strictly beats the set timestamp. Losing the set comparison means a
|
|
137
|
+
// newer (or concurrent) set already authored the field set, so a refining
|
|
138
|
+
// change cannot override it.
|
|
139
|
+
const winningFields = [];
|
|
140
|
+
const winningPatches = [];
|
|
141
|
+
for (const [path, patches] of patchesByField){
|
|
142
|
+
const currentHLC = fieldHLCs[path];
|
|
143
|
+
const beatsField = currentHLC == null || mutation.hlc > currentHLC;
|
|
144
|
+
const beatsSet = s == null || mutation.hlc > s;
|
|
145
|
+
if (beatsField && beatsSet) {
|
|
146
|
+
winningFields.push(path);
|
|
147
|
+
winningPatches.push(...patches);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// No winning field means no state change; the gate is not invoked.
|
|
151
|
+
if (winningFields.length === 0) {
|
|
152
|
+
return doc;
|
|
153
|
+
}
|
|
154
|
+
// Reconstruct the full prior field set from retained values so the validator
|
|
155
|
+
// always sees a complete object: a set carried a fully validated document and
|
|
156
|
+
// a change only refines fields it already authored.
|
|
157
|
+
const base = {};
|
|
158
|
+
for (const [path, value] of Object.entries(fieldValues)){
|
|
159
|
+
if (path === DELETE_KEY || path === SET_KEY) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
base[path.slice(1)] = value;
|
|
163
|
+
}
|
|
164
|
+
const cloned = structuredClone(base);
|
|
165
|
+
applyPatches(cloned, winningPatches, false);
|
|
166
|
+
let validated;
|
|
167
|
+
try {
|
|
168
|
+
validated = asType(validator, cloned);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
throw new MutationSchemaValidationError(docID, {
|
|
171
|
+
cause: err
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
for (const path of winningFields){
|
|
175
|
+
const field = path.slice(1);
|
|
176
|
+
fieldValues[path] = validated[field];
|
|
177
|
+
fieldHLCs[path] = mutation.hlc;
|
|
178
|
+
}
|
|
179
|
+
const assembled = assemble(fieldValues, fieldHLCs);
|
|
180
|
+
// Synthesize the post-state for gate evaluation BEFORE any write
|
|
181
|
+
// (updateFieldHLCs + saveDocument). The cloned node is dropped on deny —
|
|
182
|
+
// engine state stays identical to pre-apply.
|
|
183
|
+
if (ctx.postStateGate != null) {
|
|
184
|
+
const postState = {
|
|
185
|
+
...doc,
|
|
186
|
+
data: assembled
|
|
187
|
+
};
|
|
188
|
+
const allowed = await ctx.postStateGate(postState);
|
|
189
|
+
if (!allowed) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
194
|
+
return await ctx.store.saveDocument({
|
|
195
|
+
id,
|
|
196
|
+
existing: doc,
|
|
197
|
+
data: assembled
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
export async function applySetMutation(ctx, mutation) {
|
|
201
|
+
const owner = mutation.aud ?? mutation.iss;
|
|
202
|
+
const id = DocumentID.fromString(mutation.sub);
|
|
203
|
+
const docID = id.toString();
|
|
204
|
+
const modelID = id.model.toString();
|
|
205
|
+
// Bind owner↔ID: the document ID must hash from the document's model, the
|
|
206
|
+
// claimed owner, and the unique value. A mismatch means the writer is
|
|
207
|
+
// claiming an ID that does not derive from their own owner identity, so the
|
|
208
|
+
// write is rejected before touching any store state.
|
|
209
|
+
const expected = DocumentID.create(id.model, owner, fromB64(mutation.unq));
|
|
210
|
+
if (expected.toString() !== mutation.sub) {
|
|
211
|
+
throw new DocumentIDMismatchError(docID);
|
|
212
|
+
}
|
|
213
|
+
if (ctx.maxDriftMS != null) {
|
|
214
|
+
validateHLCDrift(mutation.hlc, ctx.maxDriftMS);
|
|
215
|
+
}
|
|
216
|
+
const [doc, validator, storedHLCs, storedValues] = await Promise.all([
|
|
217
|
+
ctx.store.getDocument(id),
|
|
218
|
+
getDocumentValidator(ctx, modelID),
|
|
219
|
+
ctx.store.getFieldHLCs(docID),
|
|
220
|
+
ctx.store.getFieldValues(docID)
|
|
221
|
+
]);
|
|
222
|
+
let data;
|
|
223
|
+
try {
|
|
224
|
+
data = asType(validator, mutation.data);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
throw new MutationSchemaValidationError(docID, {
|
|
227
|
+
cause: err
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
const fieldHLCs = {
|
|
231
|
+
...storedHLCs ?? {}
|
|
232
|
+
};
|
|
233
|
+
const fieldValues = {
|
|
234
|
+
...storedValues ?? {}
|
|
235
|
+
};
|
|
236
|
+
const s = fieldHLCs[SET_KEY];
|
|
237
|
+
// Synthesized post-state for a not-yet-persisted node, reused by both the
|
|
238
|
+
// write-access check (new-doc case) and the post-state gate. Timestamps are
|
|
239
|
+
// unknown until createDocument runs, but both gates inspect access fields
|
|
240
|
+
// (owner, data) only — not timestamps.
|
|
241
|
+
const synthesizedPostState = {
|
|
242
|
+
id: docID,
|
|
243
|
+
model: modelID,
|
|
244
|
+
owner,
|
|
245
|
+
data,
|
|
246
|
+
createdAt: new Date(0),
|
|
247
|
+
updatedAt: null
|
|
248
|
+
};
|
|
249
|
+
// A non-owner writer must present a valid write capability — for an existing
|
|
250
|
+
// doc, evaluated against the current node; for a new doc, against the
|
|
251
|
+
// synthesized post-state. Absent an injected checker, only the owner may write.
|
|
252
|
+
if (mutation.iss !== owner) {
|
|
253
|
+
const target = doc ?? synthesizedPostState;
|
|
254
|
+
const allowed = ctx.checkWriteAccess != null && await ctx.checkWriteAccess(target, mutation);
|
|
255
|
+
if (!allowed) {
|
|
256
|
+
throw new WriteAccessDeniedError(docID);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (doc != null && doc.owner !== owner) {
|
|
260
|
+
throw new OwnerChangeError(id.toString(), doc.owner, owner);
|
|
261
|
+
}
|
|
262
|
+
// Per-field LWW: a set field write wins when its HLC beats the field's
|
|
263
|
+
// current HLC. Winning writes update both the retained value and timestamp.
|
|
264
|
+
let changed = false;
|
|
265
|
+
for (const [field, value] of Object.entries(data)){
|
|
266
|
+
const path = `/${field}`;
|
|
267
|
+
const currentHLC = fieldHLCs[path];
|
|
268
|
+
if (currentHLC == null || mutation.hlc > currentHLC) {
|
|
269
|
+
fieldValues[path] = value;
|
|
270
|
+
fieldHLCs[path] = mutation.hlc;
|
|
271
|
+
changed = true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// The set timestamp advances to the greatest set HLC observed.
|
|
275
|
+
const previousSet = s;
|
|
276
|
+
fieldHLCs[SET_KEY] = s == null || mutation.hlc > s ? mutation.hlc : s;
|
|
277
|
+
const setChanged = fieldHLCs[SET_KEY] !== previousSet;
|
|
278
|
+
const assembled = assemble(fieldValues, fieldHLCs);
|
|
279
|
+
// Nothing changed: neither a field value nor the liveness-bearing set
|
|
280
|
+
// timestamp moved. The persisted node already reflects this state.
|
|
281
|
+
if (!changed && !setChanged) {
|
|
282
|
+
return doc ?? null;
|
|
283
|
+
}
|
|
284
|
+
if (doc == null) {
|
|
285
|
+
if (ctx.postStateGate != null) {
|
|
286
|
+
const postState = {
|
|
287
|
+
...synthesizedPostState,
|
|
288
|
+
data: assembled
|
|
289
|
+
};
|
|
290
|
+
const allowed = await ctx.postStateGate(postState);
|
|
291
|
+
if (!allowed) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const result = await ctx.store.createDocument({
|
|
296
|
+
id,
|
|
297
|
+
owner,
|
|
298
|
+
data: assembled,
|
|
299
|
+
unique: fromB64(mutation.unq)
|
|
300
|
+
});
|
|
301
|
+
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
// Synthesize the post-state for gate evaluation BEFORE any write
|
|
305
|
+
// (updateFieldHLCs + saveDocument). The cloned node is dropped on deny.
|
|
306
|
+
if (ctx.postStateGate != null) {
|
|
307
|
+
const postState = {
|
|
308
|
+
...doc,
|
|
309
|
+
data: assembled
|
|
310
|
+
};
|
|
311
|
+
const allowed = await ctx.postStateGate(postState);
|
|
312
|
+
if (!allowed) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
|
|
317
|
+
return await ctx.store.saveDocument({
|
|
318
|
+
id,
|
|
319
|
+
existing: doc,
|
|
320
|
+
data: assembled
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
export async function applyMutation(ctx, mutation) {
|
|
324
|
+
const mutationType = mutation.typ;
|
|
325
|
+
switch(mutation.typ){
|
|
326
|
+
case 'change':
|
|
327
|
+
return await applyChangeMutation(ctx, mutation);
|
|
328
|
+
case 'set':
|
|
329
|
+
return await applySetMutation(ctx, mutation);
|
|
330
|
+
default:
|
|
331
|
+
throw new UnsupportedMutationTypeError(mutationType);
|
|
332
|
+
}
|
|
333
|
+
}
|
package/lib/create.js
CHANGED
|
@@ -1 +1,50 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import { HLC } from '@kubun/hlc';
|
|
2
|
+
import { DocumentID } from '@kubun/id';
|
|
3
|
+
import { toB64 } from '@sozai/codec';
|
|
4
|
+
export async function createSetMutation(params) {
|
|
5
|
+
const issuer = params.issuer;
|
|
6
|
+
const owner = params.owner ?? issuer;
|
|
7
|
+
const docID = await DocumentID.create(params.modelID, owner, params.unique);
|
|
8
|
+
return {
|
|
9
|
+
typ: 'set',
|
|
10
|
+
iss: issuer,
|
|
11
|
+
aud: owner,
|
|
12
|
+
sub: docID.toString(),
|
|
13
|
+
data: params.data,
|
|
14
|
+
hlc: HLC.serialize(params.hlc.now()),
|
|
15
|
+
unq: toB64(params.unique),
|
|
16
|
+
...params.cap != null ? {
|
|
17
|
+
cap: params.cap
|
|
18
|
+
} : {}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function createChangeMutation(params) {
|
|
22
|
+
return {
|
|
23
|
+
typ: 'change',
|
|
24
|
+
iss: params.issuer,
|
|
25
|
+
sub: DocumentID.from(params.docID).toString(),
|
|
26
|
+
patch: params.patch,
|
|
27
|
+
hlc: HLC.serialize(params.hlc.now()),
|
|
28
|
+
...params.cap != null ? {
|
|
29
|
+
cap: params.cap
|
|
30
|
+
} : {}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function createRemoveMutation(params) {
|
|
34
|
+
return {
|
|
35
|
+
typ: 'change',
|
|
36
|
+
iss: params.issuer,
|
|
37
|
+
sub: params.docID,
|
|
38
|
+
patch: [
|
|
39
|
+
{
|
|
40
|
+
op: 'replace',
|
|
41
|
+
path: '/',
|
|
42
|
+
value: null
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
hlc: HLC.serialize(params.hlc.now()),
|
|
46
|
+
...params.cap != null ? {
|
|
47
|
+
cap: params.cap
|
|
48
|
+
} : {}
|
|
49
|
+
};
|
|
50
|
+
}
|
package/lib/errors.d.ts
CHANGED
|
@@ -9,3 +9,79 @@ export declare class WriteAccessDeniedError extends Error {
|
|
|
9
9
|
readonly docID: string;
|
|
10
10
|
constructor(docID: string);
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Thrown by the set apply path when the document ID in `sub` does not match the
|
|
14
|
+
* ID deterministically derived from the document's model, owner, and unique
|
|
15
|
+
* value. This binds owner↔ID so a writer cannot claim a document ID that does
|
|
16
|
+
* not hash from their own owner identity, preventing an attacker from squatting
|
|
17
|
+
* a victim's deterministic-unique document ID. Tagged with the protocol-level
|
|
18
|
+
* error code `KB09` so callers — particularly the RPC handler — can translate
|
|
19
|
+
* the throw into a structured wire response without losing the reason.
|
|
20
|
+
*/
|
|
21
|
+
export declare class DocumentIDMismatchError extends Error {
|
|
22
|
+
readonly code: "KB09";
|
|
23
|
+
readonly docID: string;
|
|
24
|
+
constructor(docID: string);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Thrown by the mutation apply path when an incoming mutation's HLC timestamp
|
|
28
|
+
* sits further in the future than the allowed drift bound. A signed timestamp
|
|
29
|
+
* is a fixed property of the mutation, so resending cannot move it back under
|
|
30
|
+
* the bound — the reject is permanent. Tagged `KB10` so the RPC handler can
|
|
31
|
+
* translate the throw into a structured wire response.
|
|
32
|
+
*/
|
|
33
|
+
export declare class HLCDriftError extends Error {
|
|
34
|
+
#private;
|
|
35
|
+
constructor(driftMS: number, maxDriftMS: number);
|
|
36
|
+
get code(): 'KB10';
|
|
37
|
+
get driftMS(): number;
|
|
38
|
+
get maxDriftMS(): number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Thrown by the set apply path when a mutation would reassign an existing
|
|
42
|
+
* document's owner. Ownership is immutable once a document exists, so this is a
|
|
43
|
+
* deterministic, permanent reject — the same mutation will always fail.
|
|
44
|
+
* Tagged `KB11` so the RPC handler can translate the throw into a structured
|
|
45
|
+
* wire response.
|
|
46
|
+
*/
|
|
47
|
+
export declare class OwnerChangeError extends Error {
|
|
48
|
+
#private;
|
|
49
|
+
constructor(docID: string, fromOwner: string, toOwner: string);
|
|
50
|
+
get code(): 'KB11';
|
|
51
|
+
get docID(): string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Thrown by the apply dispatcher when a mutation carries a type the engine does
|
|
55
|
+
* not support. The type is fixed in the signed mutation, so this is a
|
|
56
|
+
* deterministic, permanent reject. Tagged `KB12` so the RPC handler can
|
|
57
|
+
* translate the throw into a structured wire response.
|
|
58
|
+
*/
|
|
59
|
+
export declare class UnsupportedMutationTypeError extends Error {
|
|
60
|
+
#private;
|
|
61
|
+
constructor(mutationType: string);
|
|
62
|
+
get code(): 'KB12';
|
|
63
|
+
get mutationType(): string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Thrown by the apply path when a mutation's payload fails its model's schema
|
|
67
|
+
* validation. The payload is a fixed property of the signed mutation, so this
|
|
68
|
+
* is a deterministic, permanent reject. The original validation error is
|
|
69
|
+
* preserved as `cause`. Tagged `KB13` so the RPC handler can translate the
|
|
70
|
+
* throw into a structured wire response.
|
|
71
|
+
*/
|
|
72
|
+
export declare class MutationSchemaValidationError extends Error {
|
|
73
|
+
#private;
|
|
74
|
+
constructor(docID: string, options?: {
|
|
75
|
+
cause?: unknown;
|
|
76
|
+
});
|
|
77
|
+
get code(): 'KB13';
|
|
78
|
+
get docID(): string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* True when `err` carries a protocol error code identifying a deterministically
|
|
82
|
+
* permanent mutation reject. A permanent reject is one the sender cannot fix by
|
|
83
|
+
* resending (bad signature/access, malformed payload, immutable-property
|
|
84
|
+
* violation), so the apply path persists a `rejected` log row for it. Anything
|
|
85
|
+
* else — a transient store/db failure — is excluded so it stays retryable.
|
|
86
|
+
*/
|
|
87
|
+
export declare function isPermanentApplyError(err: unknown): boolean;
|
package/lib/errors.js
CHANGED
|
@@ -1 +1,136 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by the mutation apply path when a non-owner writer's capability
|
|
3
|
+
* check fails. Tagged with the protocol-level error code `KB07` so callers
|
|
4
|
+
* — particularly the RPC handler — can translate the throw into a
|
|
5
|
+
* structured wire response without losing the reason.
|
|
6
|
+
*/ export class WriteAccessDeniedError extends Error {
|
|
7
|
+
code = 'KB07';
|
|
8
|
+
docID;
|
|
9
|
+
constructor(docID){
|
|
10
|
+
super(`Access denied: cannot write document ${docID}`);
|
|
11
|
+
this.name = 'WriteAccessDeniedError';
|
|
12
|
+
this.docID = docID;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Thrown by the set apply path when the document ID in `sub` does not match the
|
|
17
|
+
* ID deterministically derived from the document's model, owner, and unique
|
|
18
|
+
* value. This binds owner↔ID so a writer cannot claim a document ID that does
|
|
19
|
+
* not hash from their own owner identity, preventing an attacker from squatting
|
|
20
|
+
* a victim's deterministic-unique document ID. Tagged with the protocol-level
|
|
21
|
+
* error code `KB09` so callers — particularly the RPC handler — can translate
|
|
22
|
+
* the throw into a structured wire response without losing the reason.
|
|
23
|
+
*/ export class DocumentIDMismatchError extends Error {
|
|
24
|
+
code = 'KB09';
|
|
25
|
+
docID;
|
|
26
|
+
constructor(docID){
|
|
27
|
+
super(`Document ID mismatch: ${docID} is not bound to its owner and unique value`);
|
|
28
|
+
this.name = 'DocumentIDMismatchError';
|
|
29
|
+
this.docID = docID;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Thrown by the mutation apply path when an incoming mutation's HLC timestamp
|
|
34
|
+
* sits further in the future than the allowed drift bound. A signed timestamp
|
|
35
|
+
* is a fixed property of the mutation, so resending cannot move it back under
|
|
36
|
+
* the bound — the reject is permanent. Tagged `KB10` so the RPC handler can
|
|
37
|
+
* translate the throw into a structured wire response.
|
|
38
|
+
*/ export class HLCDriftError extends Error {
|
|
39
|
+
#driftMS;
|
|
40
|
+
#maxDriftMS;
|
|
41
|
+
constructor(driftMS, maxDriftMS){
|
|
42
|
+
super(`HLC timestamp too far in the future: ${driftMS}ms drift exceeds ${maxDriftMS}ms limit`);
|
|
43
|
+
this.name = 'HLCDriftError';
|
|
44
|
+
this.#driftMS = driftMS;
|
|
45
|
+
this.#maxDriftMS = maxDriftMS;
|
|
46
|
+
}
|
|
47
|
+
get code() {
|
|
48
|
+
return 'KB10';
|
|
49
|
+
}
|
|
50
|
+
get driftMS() {
|
|
51
|
+
return this.#driftMS;
|
|
52
|
+
}
|
|
53
|
+
get maxDriftMS() {
|
|
54
|
+
return this.#maxDriftMS;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Thrown by the set apply path when a mutation would reassign an existing
|
|
59
|
+
* document's owner. Ownership is immutable once a document exists, so this is a
|
|
60
|
+
* deterministic, permanent reject — the same mutation will always fail.
|
|
61
|
+
* Tagged `KB11` so the RPC handler can translate the throw into a structured
|
|
62
|
+
* wire response.
|
|
63
|
+
*/ export class OwnerChangeError extends Error {
|
|
64
|
+
#docID;
|
|
65
|
+
constructor(docID, fromOwner, toOwner){
|
|
66
|
+
super(`Cannot change owner from ${fromOwner} to ${toOwner} in document: ${docID}`);
|
|
67
|
+
this.name = 'OwnerChangeError';
|
|
68
|
+
this.#docID = docID;
|
|
69
|
+
}
|
|
70
|
+
get code() {
|
|
71
|
+
return 'KB11';
|
|
72
|
+
}
|
|
73
|
+
get docID() {
|
|
74
|
+
return this.#docID;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Thrown by the apply dispatcher when a mutation carries a type the engine does
|
|
79
|
+
* not support. The type is fixed in the signed mutation, so this is a
|
|
80
|
+
* deterministic, permanent reject. Tagged `KB12` so the RPC handler can
|
|
81
|
+
* translate the throw into a structured wire response.
|
|
82
|
+
*/ export class UnsupportedMutationTypeError extends Error {
|
|
83
|
+
#mutationType;
|
|
84
|
+
constructor(mutationType){
|
|
85
|
+
super(`Unsupported mutation type: ${mutationType}`);
|
|
86
|
+
this.name = 'UnsupportedMutationTypeError';
|
|
87
|
+
this.#mutationType = mutationType;
|
|
88
|
+
}
|
|
89
|
+
get code() {
|
|
90
|
+
return 'KB12';
|
|
91
|
+
}
|
|
92
|
+
get mutationType() {
|
|
93
|
+
return this.#mutationType;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Thrown by the apply path when a mutation's payload fails its model's schema
|
|
98
|
+
* validation. The payload is a fixed property of the signed mutation, so this
|
|
99
|
+
* is a deterministic, permanent reject. The original validation error is
|
|
100
|
+
* preserved as `cause`. Tagged `KB13` so the RPC handler can translate the
|
|
101
|
+
* throw into a structured wire response.
|
|
102
|
+
*/ export class MutationSchemaValidationError extends Error {
|
|
103
|
+
#docID;
|
|
104
|
+
constructor(docID, options){
|
|
105
|
+
super(`Mutation payload failed model schema validation for document ${docID}`, options);
|
|
106
|
+
this.name = 'MutationSchemaValidationError';
|
|
107
|
+
this.#docID = docID;
|
|
108
|
+
}
|
|
109
|
+
get code() {
|
|
110
|
+
return 'KB13';
|
|
111
|
+
}
|
|
112
|
+
get docID() {
|
|
113
|
+
return this.#docID;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const PERMANENT_APPLY_ERROR_CODES = new Set([
|
|
117
|
+
'KB07',
|
|
118
|
+
'KB09',
|
|
119
|
+
'KB10',
|
|
120
|
+
'KB11',
|
|
121
|
+
'KB12',
|
|
122
|
+
'KB13'
|
|
123
|
+
]);
|
|
124
|
+
/**
|
|
125
|
+
* True when `err` carries a protocol error code identifying a deterministically
|
|
126
|
+
* permanent mutation reject. A permanent reject is one the sender cannot fix by
|
|
127
|
+
* resending (bad signature/access, malformed payload, immutable-property
|
|
128
|
+
* violation), so the apply path persists a `rejected` log row for it. Anything
|
|
129
|
+
* else — a transient store/db failure — is excluded so it stays retryable.
|
|
130
|
+
*/ export function isPermanentApplyError(err) {
|
|
131
|
+
if (typeof err === 'object' && err != null && 'code' in err) {
|
|
132
|
+
const code = err.code;
|
|
133
|
+
return typeof code === 'string' && PERMANENT_APPLY_ERROR_CODES.has(code);
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -3,6 +3,6 @@ export type { DocumentValidator, MutationContext, MutationDB, PostStateGate, Val
|
|
|
3
3
|
export { applyChangeMutation, applyMutation, applySetMutation, getTopLevelField } from './apply.js';
|
|
4
4
|
export type { CreateChangeMutationParams, CreateRemoveMutationParams, CreateSetMutationParams, } from './create.js';
|
|
5
5
|
export { createChangeMutation, createRemoveMutation, createSetMutation } from './create.js';
|
|
6
|
-
export { WriteAccessDeniedError } from './errors.js';
|
|
6
|
+
export { DocumentIDMismatchError, HLCDriftError, isPermanentApplyError, MutationSchemaValidationError, OwnerChangeError, UnsupportedMutationTypeError, WriteAccessDeniedError, } from './errors.js';
|
|
7
7
|
export type { CreateDocumentParams, CreateMutationOperationsParams, MutationOperations, RemoveDocumentParams, SetDocumentParams, UpdateDocumentParams, } from './operations.js';
|
|
8
8
|
export { convertPatchInput, createMutationOperations } from './operations.js';
|
package/lib/index.js
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
export{HLC}from
|
|
1
|
+
export { HLC } from '@kubun/hlc';
|
|
2
|
+
export { applyChangeMutation, applyMutation, applySetMutation, getTopLevelField } from './apply.js';
|
|
3
|
+
export { createChangeMutation, createRemoveMutation, createSetMutation } from './create.js';
|
|
4
|
+
export { DocumentIDMismatchError, HLCDriftError, isPermanentApplyError, MutationSchemaValidationError, OwnerChangeError, UnsupportedMutationTypeError, WriteAccessDeniedError } from './errors.js';
|
|
5
|
+
export { convertPatchInput, createMutationOperations } from './operations.js';
|
package/lib/operations.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import type { GetRandomValues } from '@enkaku/runtime';
|
|
2
1
|
import type { PatchOperation } from '@kubun/graphql';
|
|
3
2
|
import type { HLC } from '@kubun/hlc';
|
|
4
3
|
import type { ChangeDocumentMutation, DocumentData, SetDocumentMutation } from '@kubun/protocol';
|
|
4
|
+
import type { GetRandomValues } from '@sozai/runtime';
|
|
5
5
|
export type CreateMutationOperationsParams<T> = {
|
|
6
6
|
issuer: string;
|
|
7
7
|
hlc: HLC;
|
|
8
8
|
getRandomValues: GetRandomValues;
|
|
9
|
+
owner?: string;
|
|
9
10
|
cap?: string | Array<string>;
|
|
10
11
|
processSetMutation(mutation: SetDocumentMutation, transactionID?: string | null): Promise<T>;
|
|
11
12
|
processChangeMutation(mutation: ChangeDocumentMutation, transactionID?: string | null): Promise<T>;
|
|
@@ -14,12 +15,22 @@ export type CreateDocumentParams = {
|
|
|
14
15
|
modelID: string;
|
|
15
16
|
data: DocumentData;
|
|
16
17
|
transactionID?: string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Per-call document owner, overriding the ops-level owner for this write only.
|
|
20
|
+
* Omitted means owner = the ops-level owner (or the signer when unset).
|
|
21
|
+
*/
|
|
22
|
+
owner?: string;
|
|
17
23
|
};
|
|
18
24
|
export type SetDocumentParams = {
|
|
19
25
|
modelID: string;
|
|
20
26
|
unique: Uint8Array;
|
|
21
27
|
data: DocumentData;
|
|
22
28
|
transactionID?: string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Per-call document owner, overriding the ops-level owner for this write only.
|
|
31
|
+
* Omitted means owner = the ops-level owner (or the signer when unset).
|
|
32
|
+
*/
|
|
33
|
+
owner?: string;
|
|
23
34
|
};
|
|
24
35
|
export type UpdateDocumentParams = {
|
|
25
36
|
docID: string;
|
package/lib/operations.js
CHANGED
|
@@ -1 +1,61 @@
|
|
|
1
|
-
import{createChangeMutation
|
|
1
|
+
import { createChangeMutation, createRemoveMutation, createSetMutation } from './create.js';
|
|
2
|
+
export function createMutationOperations(params) {
|
|
3
|
+
const { issuer, hlc, getRandomValues, owner, cap, processSetMutation, processChangeMutation } = params;
|
|
4
|
+
return {
|
|
5
|
+
async createDocument (params) {
|
|
6
|
+
const { modelID, data, transactionID } = params;
|
|
7
|
+
const unique = getRandomValues(new Uint8Array(12));
|
|
8
|
+
const mutation = await createSetMutation({
|
|
9
|
+
issuer,
|
|
10
|
+
hlc,
|
|
11
|
+
modelID,
|
|
12
|
+
data,
|
|
13
|
+
unique,
|
|
14
|
+
owner: params.owner ?? owner,
|
|
15
|
+
cap
|
|
16
|
+
});
|
|
17
|
+
return await processSetMutation(mutation, transactionID);
|
|
18
|
+
},
|
|
19
|
+
async setDocument (params) {
|
|
20
|
+
const { modelID, unique, data, transactionID } = params;
|
|
21
|
+
const mutation = await createSetMutation({
|
|
22
|
+
issuer,
|
|
23
|
+
hlc,
|
|
24
|
+
modelID,
|
|
25
|
+
data,
|
|
26
|
+
unique,
|
|
27
|
+
owner: params.owner ?? owner,
|
|
28
|
+
cap
|
|
29
|
+
});
|
|
30
|
+
return await processSetMutation(mutation, transactionID);
|
|
31
|
+
},
|
|
32
|
+
async updateDocument ({ docID, patch, transactionID }) {
|
|
33
|
+
const mutation = createChangeMutation({
|
|
34
|
+
issuer,
|
|
35
|
+
hlc,
|
|
36
|
+
docID,
|
|
37
|
+
patch,
|
|
38
|
+
cap
|
|
39
|
+
});
|
|
40
|
+
return await processChangeMutation(mutation, transactionID);
|
|
41
|
+
},
|
|
42
|
+
async removeDocument ({ docID, transactionID }) {
|
|
43
|
+
const mutation = createRemoveMutation({
|
|
44
|
+
docID,
|
|
45
|
+
issuer,
|
|
46
|
+
hlc,
|
|
47
|
+
cap
|
|
48
|
+
});
|
|
49
|
+
return await processChangeMutation(mutation, transactionID);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function convertPatchInput(patch) {
|
|
54
|
+
return patch.map((p)=>{
|
|
55
|
+
const [op, rest] = Object.entries(p)[0];
|
|
56
|
+
return {
|
|
57
|
+
...rest,
|
|
58
|
+
op
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/mutation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"type": "module",
|
|
@@ -15,20 +15,20 @@
|
|
|
15
15
|
],
|
|
16
16
|
"sideEffects": false,
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@
|
|
19
|
-
"@
|
|
20
|
-
"@
|
|
21
|
-
"@
|
|
22
|
-
"@kubun/hlc": "^0.
|
|
23
|
-
"@kubun/id": "^0.
|
|
18
|
+
"@sozai/codec": "^0.1.0",
|
|
19
|
+
"@sozai/patch": "^0.1.1",
|
|
20
|
+
"@sozai/runtime": "^0.1.0",
|
|
21
|
+
"@sozai/schema": "^0.1.0",
|
|
22
|
+
"@kubun/hlc": "^0.11.0",
|
|
23
|
+
"@kubun/id": "^0.11.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@kubun/
|
|
27
|
-
"@kubun/
|
|
26
|
+
"@kubun/graphql": "^0.11.0",
|
|
27
|
+
"@kubun/protocol": "^0.11.0"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build:clean": "del lib",
|
|
31
|
-
"build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
|
|
31
|
+
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
32
32
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
33
33
|
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
34
34
|
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|