@kubun/mutation 0.10.1 → 0.12.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 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
- updateFieldHLCs(docID: DocumentID, fieldHLCs: Record<string, string>): Promise<void>;
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,426 @@
1
- import{fromB64 as t}from"@enkaku/codec";import{applyPatches as e}from"@enkaku/patch";import{asType as a,createValidator as r}from"@enkaku/schema";import{HLC as l}from"@kubun/hlc";import{DocumentID as o}from"@kubun/id";import{WriteAccessDeniedError as n}from"./errors.js";export function getDocumentValidator(t,e){return null==t.validators[e]&&(t.validators[e]=t.store.getDocumentModel(e).then(t=>r({...t.schema,$id:e}))),t.validators[e]}function i(t,e){let a=l.parse(t),r=Date.now(),o=a.wallTime-r;if(o>e)throw Error(`HLC timestamp too far in the future: ${o}ms drift exceeds ${e}ms limit`)}export function getTopLevelField(t){let e=t.indexOf("/",1);return -1===e?t:t.slice(0,e)}export async function applyChangeMutation(t,r){let l=o.fromString(r.sub),u=l.toString(),s=await t.store.getDocument(l);if(null==s)throw Error(`Document not found: ${u}`);if(r.iss!==s.owner&&!(null!=t.checkWriteAccess&&await t.checkWriteAccess(s,r)))throw new n(u);null!=t.maxDriftMS&&i(r.hlc,t.maxDriftMS);let c=l.model.toString(),p=await t.store.getFieldHLCs(u)??{};if(1===r.patch.length&&"replace"===r.patch[0].op&&"/"===r.patch[0].path&&null===r.patch[0].value){let e={...p,"/":r.hlc},a=Object.entries(e).every(([t,e])=>"/"===t||r.hlc>=e);if(a&&null!=t.postStateGate){let e={...s,data:null};if(!await t.postStateGate(e))return null}return(await t.store.updateFieldHLCs(l,e),a)?await t.store.saveDocument({id:l,existing:s,data:null}):s}if(null!=p["/"]&&null===s.data)return s;let f=await getDocumentValidator(t,c),d=new Map;for(let t of r.patch){let e=getTopLevelField(t.path),a=d.get(e);null!=a?a.push(t):d.set(e,[t])}let m={...p},h=[];for(let[t,e]of d){let a=p[t];(null==a||r.hlc>a)&&(h.push(...e),m[t]=r.hlc)}if(0===h.length)return s;let w=structuredClone(s.data??{});e(w,h,!1);let g=a(f,w);if(null!=t.postStateGate){let e={...s,data:g};if(!await t.postStateGate(e))return null}return await t.store.updateFieldHLCs(l,m),await t.store.saveDocument({id:l,existing:s,data:g})}export async function applySetMutation(e,r){let l=r.aud??r.iss,u=o.fromString(r.sub),s=u.toString(),c=u.model.toString();null!=e.maxDriftMS&&i(r.hlc,e.maxDriftMS);let[p,f]=await Promise.all([e.store.getDocument(u),getDocumentValidator(e,c)]),d=a(f,r.data),m={};for(let t of Object.keys(d))m[`/${t}`]=r.hlc;let h={id:s,model:c,owner:l,data:d,createdAt:new Date(0),updatedAt:null};if(r.iss!==l){let t=p??h;if(!(null!=e.checkWriteAccess&&await e.checkWriteAccess(t,r)))throw new n(s)}if(null===p){if(null!=e.postStateGate&&!await e.postStateGate(h))return null;let a=await e.store.createDocument({id:u,owner:l,data:d,unique:t(r.unq)});return await e.store.updateFieldHLCs(u,m),a}if(p.owner!==l)throw Error(`Cannot change owner from ${p.owner} to ${l} in document: ${u.toString()}`);let w=await e.store.getFieldHLCs(s)??{},g={...p.data??{}},S={...w},D=!1;for(let[t,e]of Object.entries(d)){let a=`/${t}`,l=w[a];(null==l||r.hlc>l)&&(g[t]=e,S[a]=r.hlc,D=!0)}if(!D)return p;let x=a(f,g);if(null!=e.postStateGate){let t={...p,data:x};if(!await e.postStateGate(t))return null}return await e.store.updateFieldHLCs(u,S),await e.store.saveDocument({id:u,existing:p,data:x})}export async function applyMutation(t,e){switch(e.typ){case"change":return await applyChangeMutation(t,e);case"set":return await applySetMutation(t,e);default:throw Error("Unsupported mutation type")}}
1
+ import { HLC } from '@kubun/hlc';
2
+ import { DocumentID } from '@kubun/id';
3
+ import { isValidAccessRule, RESERVED_DOCUMENT_FIELDS } from '@kubun/protocol';
4
+ import { fromB64 } from '@sozai/codec';
5
+ import { applyPatches } from '@sozai/patch';
6
+ import { asType, createValidator } from '@sozai/schema';
7
+ import { DocumentIDMismatchError, HLCDriftError, MutationSchemaValidationError, OwnerChangeError, UnsupportedMutationTypeError, WriteAccessDeniedError } from './errors.js';
8
+ /**
9
+ * Reserved `field_hlcs` key holding the greatest delete HLC observed for the
10
+ * document (the tombstone timestamp `t`).
11
+ */ const DELETE_KEY = '/';
12
+ /**
13
+ * Reserved `field_hlcs` key holding the greatest set HLC observed for the
14
+ * document (the set timestamp `s`).
15
+ */ const SET_KEY = '$set';
16
+ export function getDocumentValidator(ctx, id) {
17
+ if (ctx.validators[id] == null) {
18
+ ctx.validators[id] = ctx.store.getDocumentModel(id).then((model)=>createValidator({
19
+ ...model.schema,
20
+ $id: id
21
+ })).catch((err)=>{
22
+ delete ctx.validators[id];
23
+ throw err;
24
+ });
25
+ }
26
+ return ctx.validators[id];
27
+ }
28
+ function validateHLCDrift(hlcString, maxDriftMS) {
29
+ const ts = HLC.parse(hlcString);
30
+ const now = Date.now();
31
+ const drift = ts.wallTime - now;
32
+ if (drift > maxDriftMS) {
33
+ throw new HLCDriftError(drift, maxDriftMS);
34
+ }
35
+ }
36
+ export function getTopLevelField(path) {
37
+ const secondSlash = path.indexOf('/', 1);
38
+ return secondSlash === -1 ? path : path.slice(0, secondSlash);
39
+ }
40
+ const RESERVED_FIELD_SET = new Set(RESERVED_DOCUMENT_FIELDS);
41
+ const ACCESS_PERMISSIONS_FIELD = 'accessPermissions';
42
+ /**
43
+ * Salvage a candidate `accessPermissions` value per rule, mirroring the engine
44
+ * parser (`parseDocumentAccessPermissions`): a malformed `read` or `write` rule
45
+ * is dropped on its own while a valid sibling — and any unknown extra keys —
46
+ * survive. Returns `null` only when the value is not a plain object or no valid
47
+ * rule remains; otherwise a sanitized copy carrying just the valid rules (plus
48
+ * unrecognized keys the parser ignores). Enforcing per-rule at the storage
49
+ * boundary means the SQL read predicates only ever see valid shapes or absence,
50
+ * and a partly-garbage object never discards a legitimate sibling rule.
51
+ */ function sanitizeAccessPermissions(value) {
52
+ if (value == null || typeof value !== 'object') {
53
+ return null;
54
+ }
55
+ const perms = value;
56
+ const readValid = perms.read !== undefined && isValidAccessRule(perms.read);
57
+ const writeValid = perms.write !== undefined && isValidAccessRule(perms.write);
58
+ if (!readValid && !writeValid) {
59
+ return null;
60
+ }
61
+ const sanitized = {
62
+ ...perms
63
+ };
64
+ if (perms.read !== undefined && !readValid) {
65
+ delete sanitized.read;
66
+ }
67
+ if (perms.write !== undefined && !writeValid) {
68
+ delete sanitized.write;
69
+ }
70
+ return sanitized;
71
+ }
72
+ /**
73
+ * Partition a candidate document into model-declared fields and engine-reserved
74
+ * fields. Only the model half is validated against the model schema; the
75
+ * reserved half is re-attached to the persisted result unchanged.
76
+ *
77
+ * A malformed `accessPermissions` value is sanitized per rule here rather than
78
+ * rejecting the mutation: rejecting a signed remote mutation the owner's log
79
+ * already contains would fork merkle sync permanently, whereas sanitizing keeps
80
+ * document-data convergence and lets access resolve to model/server defaults.
81
+ * A salvaged value keeps its valid rules (added to `reserved`); the field is
82
+ * added to `droppedReserved` and left out of `reserved` only when nothing valid
83
+ * remains. A well-behaved producer never emits garbage (mint-time validation
84
+ * catches it first), so this only fires on hostile or buggy remote input.
85
+ */ function splitReservedFields(data) {
86
+ const model = {};
87
+ const reserved = {};
88
+ const droppedReserved = new Set();
89
+ for (const [key, value] of Object.entries(data)){
90
+ if (RESERVED_FIELD_SET.has(key)) {
91
+ if (key === ACCESS_PERMISSIONS_FIELD) {
92
+ const sanitized = sanitizeAccessPermissions(value);
93
+ if (sanitized == null) {
94
+ droppedReserved.add(key);
95
+ continue;
96
+ }
97
+ reserved[key] = sanitized;
98
+ continue;
99
+ }
100
+ reserved[key] = value;
101
+ } else {
102
+ model[key] = value;
103
+ }
104
+ }
105
+ return {
106
+ model,
107
+ reserved,
108
+ droppedReserved
109
+ };
110
+ }
111
+ /**
112
+ * Compute the visible document data from the per-field winning values and the
113
+ * reserved set/delete timestamps. A document is alive when it carries a set
114
+ * timestamp that is strictly greater than any tombstone timestamp; only then
115
+ * does it surface data. The visible object is assembled solely from the
116
+ * retained field values (keyed by `/field` paths), so the result is a pure
117
+ * function of the accumulated state regardless of the order mutations arrived.
118
+ */ function assemble(fieldValues, fieldHLCs) {
119
+ const s = fieldHLCs[SET_KEY];
120
+ const t = fieldHLCs[DELETE_KEY];
121
+ const alive = s != null && (t == null || s > t);
122
+ if (!alive) {
123
+ return null;
124
+ }
125
+ const data = {};
126
+ for (const [path, value] of Object.entries(fieldValues)){
127
+ if (path === DELETE_KEY || path === SET_KEY) {
128
+ continue;
129
+ }
130
+ data[path.slice(1)] = value;
131
+ }
132
+ return data;
133
+ }
134
+ export async function applyChangeMutation(ctx, mutation) {
135
+ const id = DocumentID.fromString(mutation.sub);
136
+ const docID = id.toString();
137
+ const doc = await ctx.store.getDocument(id);
138
+ if (doc == null) {
139
+ throw new Error(`Document not found: ${docID}`);
140
+ }
141
+ // A non-owner writer must present a valid write capability. Absent an
142
+ // injected checker, only the owner may write (owner-only safe default).
143
+ if (mutation.iss !== doc.owner) {
144
+ const allowed = ctx.checkWriteAccess != null && await ctx.checkWriteAccess(doc, mutation);
145
+ if (!allowed) {
146
+ throw new WriteAccessDeniedError(docID);
147
+ }
148
+ }
149
+ if (ctx.maxDriftMS != null) {
150
+ validateHLCDrift(mutation.hlc, ctx.maxDriftMS);
151
+ }
152
+ const modelID = id.model.toString();
153
+ const [storedHLCs, storedValues] = await Promise.all([
154
+ ctx.store.getFieldHLCs(docID),
155
+ ctx.store.getFieldValues(docID)
156
+ ]);
157
+ const fieldHLCs = {
158
+ ...storedHLCs ?? {}
159
+ };
160
+ const fieldValues = {
161
+ ...storedValues ?? {}
162
+ };
163
+ const s = fieldHLCs[SET_KEY];
164
+ const t = fieldHLCs[DELETE_KEY];
165
+ // A delete is a single root replace to null. It only advances the tombstone
166
+ // timestamp; it never erases retained field values or field timestamps, so a
167
+ // later set above the tombstone can revive the exact prior field set.
168
+ const isDelete = mutation.patch.length === 1 && mutation.patch[0].op === 'replace' && mutation.patch[0].path === DELETE_KEY && mutation.patch[0].value === null;
169
+ if (isDelete) {
170
+ fieldHLCs[DELETE_KEY] = t == null || mutation.hlc > t ? mutation.hlc : t;
171
+ const assembled = assemble(fieldValues, fieldHLCs);
172
+ // The gate guards state-changing applies. A delete can only move a live
173
+ // document to dead (it never revives one and never alters field values), so
174
+ // visible state changes exactly when a previously-live document becomes
175
+ // tombstoned. Any other delete is a no-op for visible state.
176
+ if (doc.data != null && assembled == null && ctx.postStateGate != null) {
177
+ const postState = {
178
+ ...doc,
179
+ data: assembled
180
+ };
181
+ const allowed = await ctx.postStateGate(postState);
182
+ if (!allowed) {
183
+ return null;
184
+ }
185
+ }
186
+ await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
187
+ return await ctx.store.saveDocument({
188
+ id,
189
+ existing: doc,
190
+ data: assembled
191
+ });
192
+ }
193
+ const validator = await getDocumentValidator(ctx, modelID);
194
+ // Group patches by top-level field for LWW comparison.
195
+ const patchesByField = new Map();
196
+ for (const patch of mutation.patch){
197
+ const field = getTopLevelField(patch.path);
198
+ const group = patchesByField.get(field);
199
+ if (group != null) {
200
+ group.push(patch);
201
+ } else {
202
+ patchesByField.set(field, [
203
+ patch
204
+ ]);
205
+ }
206
+ }
207
+ // A change field write wins when its HLC beats the field's current HLC and
208
+ // also strictly beats the set timestamp. Losing the set comparison means a
209
+ // newer (or concurrent) set already authored the field set, so a refining
210
+ // change cannot override it.
211
+ const winningFields = [];
212
+ const winningPatches = [];
213
+ for (const [path, patches] of patchesByField){
214
+ const currentHLC = fieldHLCs[path];
215
+ const beatsField = currentHLC == null || mutation.hlc > currentHLC;
216
+ const beatsSet = s == null || mutation.hlc > s;
217
+ if (beatsField && beatsSet) {
218
+ winningFields.push(path);
219
+ winningPatches.push(...patches);
220
+ }
221
+ }
222
+ // No winning field means no state change; the gate is not invoked.
223
+ if (winningFields.length === 0) {
224
+ return doc;
225
+ }
226
+ // Reconstruct the full prior field set from retained values so the validator
227
+ // always sees a complete object: a set carried a fully validated document and
228
+ // a change only refines fields it already authored.
229
+ const base = {};
230
+ for (const [path, value] of Object.entries(fieldValues)){
231
+ if (path === DELETE_KEY || path === SET_KEY) {
232
+ continue;
233
+ }
234
+ base[path.slice(1)] = value;
235
+ }
236
+ const cloned = structuredClone(base);
237
+ applyPatches(cloned, winningPatches, false);
238
+ // Reserved fields are engine metadata the model schema never declares. Split
239
+ // them out before validating the model-declared remainder, then re-attach so
240
+ // they persist unchanged (a patch on a reserved field applies as normal).
241
+ const { model: modelFields, reserved, droppedReserved } = splitReservedFields(cloned);
242
+ let validatedModel;
243
+ try {
244
+ validatedModel = asType(validator, modelFields);
245
+ } catch (err) {
246
+ throw new MutationSchemaValidationError(docID, {
247
+ cause: err
248
+ });
249
+ }
250
+ const validated = {
251
+ ...validatedModel,
252
+ ...reserved
253
+ };
254
+ for (const path of winningFields){
255
+ const field = path.slice(1);
256
+ // A dropped reserved field (malformed accessPermissions) is neither
257
+ // persisted nor allowed to advance its field HLC, so a later valid update
258
+ // from the owner still wins the LWW comparison and applies.
259
+ if (droppedReserved.has(field)) {
260
+ continue;
261
+ }
262
+ fieldValues[path] = validated[field];
263
+ fieldHLCs[path] = mutation.hlc;
264
+ }
265
+ const assembled = assemble(fieldValues, fieldHLCs);
266
+ // Synthesize the post-state for gate evaluation BEFORE any write
267
+ // (updateFieldHLCs + saveDocument). The cloned node is dropped on deny —
268
+ // engine state stays identical to pre-apply.
269
+ if (ctx.postStateGate != null) {
270
+ const postState = {
271
+ ...doc,
272
+ data: assembled
273
+ };
274
+ const allowed = await ctx.postStateGate(postState);
275
+ if (!allowed) {
276
+ return null;
277
+ }
278
+ }
279
+ await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
280
+ return await ctx.store.saveDocument({
281
+ id,
282
+ existing: doc,
283
+ data: assembled
284
+ });
285
+ }
286
+ export async function applySetMutation(ctx, mutation) {
287
+ const owner = mutation.aud ?? mutation.iss;
288
+ const id = DocumentID.fromString(mutation.sub);
289
+ const docID = id.toString();
290
+ const modelID = id.model.toString();
291
+ // Bind owner↔ID: the document ID must hash from the document's model, the
292
+ // claimed owner, and the unique value. A mismatch means the writer is
293
+ // claiming an ID that does not derive from their own owner identity, so the
294
+ // write is rejected before touching any store state.
295
+ const expected = DocumentID.create(id.model, owner, fromB64(mutation.unq));
296
+ if (expected.toString() !== mutation.sub) {
297
+ throw new DocumentIDMismatchError(docID);
298
+ }
299
+ if (ctx.maxDriftMS != null) {
300
+ validateHLCDrift(mutation.hlc, ctx.maxDriftMS);
301
+ }
302
+ const [doc, validator, storedHLCs, storedValues] = await Promise.all([
303
+ ctx.store.getDocument(id),
304
+ getDocumentValidator(ctx, modelID),
305
+ ctx.store.getFieldHLCs(docID),
306
+ ctx.store.getFieldValues(docID)
307
+ ]);
308
+ // Reserved fields are engine metadata the model schema never declares. Split
309
+ // them out before validating the model-declared remainder, then re-attach so
310
+ // they persist unchanged.
311
+ const { model: modelData, reserved } = splitReservedFields(mutation.data);
312
+ let data;
313
+ try {
314
+ data = {
315
+ ...asType(validator, modelData),
316
+ ...reserved
317
+ };
318
+ } catch (err) {
319
+ throw new MutationSchemaValidationError(docID, {
320
+ cause: err
321
+ });
322
+ }
323
+ const fieldHLCs = {
324
+ ...storedHLCs ?? {}
325
+ };
326
+ const fieldValues = {
327
+ ...storedValues ?? {}
328
+ };
329
+ const s = fieldHLCs[SET_KEY];
330
+ // Synthesized post-state for a not-yet-persisted node, reused by both the
331
+ // write-access check (new-doc case) and the post-state gate. Timestamps are
332
+ // unknown until createDocument runs, but both gates inspect access fields
333
+ // (owner, data) only — not timestamps.
334
+ const synthesizedPostState = {
335
+ id: docID,
336
+ model: modelID,
337
+ owner,
338
+ data,
339
+ createdAt: new Date(0),
340
+ updatedAt: null
341
+ };
342
+ // A non-owner writer must present a valid write capability — for an existing
343
+ // doc, evaluated against the current node; for a new doc, against the
344
+ // synthesized post-state. Absent an injected checker, only the owner may write.
345
+ if (mutation.iss !== owner) {
346
+ const target = doc ?? synthesizedPostState;
347
+ const allowed = ctx.checkWriteAccess != null && await ctx.checkWriteAccess(target, mutation);
348
+ if (!allowed) {
349
+ throw new WriteAccessDeniedError(docID);
350
+ }
351
+ }
352
+ if (doc != null && doc.owner !== owner) {
353
+ throw new OwnerChangeError(id.toString(), doc.owner, owner);
354
+ }
355
+ // Per-field LWW: a set field write wins when its HLC beats the field's
356
+ // current HLC. Winning writes update both the retained value and timestamp.
357
+ let changed = false;
358
+ for (const [field, value] of Object.entries(data)){
359
+ const path = `/${field}`;
360
+ const currentHLC = fieldHLCs[path];
361
+ if (currentHLC == null || mutation.hlc > currentHLC) {
362
+ fieldValues[path] = value;
363
+ fieldHLCs[path] = mutation.hlc;
364
+ changed = true;
365
+ }
366
+ }
367
+ // The set timestamp advances to the greatest set HLC observed.
368
+ const previousSet = s;
369
+ fieldHLCs[SET_KEY] = s == null || mutation.hlc > s ? mutation.hlc : s;
370
+ const setChanged = fieldHLCs[SET_KEY] !== previousSet;
371
+ const assembled = assemble(fieldValues, fieldHLCs);
372
+ // Nothing changed: neither a field value nor the liveness-bearing set
373
+ // timestamp moved. The persisted node already reflects this state.
374
+ if (!changed && !setChanged) {
375
+ return doc ?? null;
376
+ }
377
+ if (doc == null) {
378
+ if (ctx.postStateGate != null) {
379
+ const postState = {
380
+ ...synthesizedPostState,
381
+ data: assembled
382
+ };
383
+ const allowed = await ctx.postStateGate(postState);
384
+ if (!allowed) {
385
+ return null;
386
+ }
387
+ }
388
+ const result = await ctx.store.createDocument({
389
+ id,
390
+ owner,
391
+ data: assembled,
392
+ unique: fromB64(mutation.unq)
393
+ });
394
+ await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
395
+ return result;
396
+ }
397
+ // Synthesize the post-state for gate evaluation BEFORE any write
398
+ // (updateFieldHLCs + saveDocument). The cloned node is dropped on deny.
399
+ if (ctx.postStateGate != null) {
400
+ const postState = {
401
+ ...doc,
402
+ data: assembled
403
+ };
404
+ const allowed = await ctx.postStateGate(postState);
405
+ if (!allowed) {
406
+ return null;
407
+ }
408
+ }
409
+ await ctx.store.updateFieldHLCs(id, fieldHLCs, fieldValues);
410
+ return await ctx.store.saveDocument({
411
+ id,
412
+ existing: doc,
413
+ data: assembled
414
+ });
415
+ }
416
+ export async function applyMutation(ctx, mutation) {
417
+ const mutationType = mutation.typ;
418
+ switch(mutation.typ){
419
+ case 'change':
420
+ return await applyChangeMutation(ctx, mutation);
421
+ case 'set':
422
+ return await applySetMutation(ctx, mutation);
423
+ default:
424
+ throw new UnsupportedMutationTypeError(mutationType);
425
+ }
426
+ }
package/lib/create.js CHANGED
@@ -1 +1,76 @@
1
- import{toB64 as e}from"@enkaku/codec";import{HLC as t}from"@kubun/hlc";import{DocumentID as a}from"@kubun/id";export async function createSetMutation(c){let n=c.issuer,u=c.owner??n,r=await a.create(c.modelID,u,c.unique);return{typ:"set",iss:n,aud:u,sub:r.toString(),data:c.data,hlc:t.serialize(c.hlc.now()),unq:e(c.unique),...null!=c.cap?{cap:c.cap}:{}}}export function createChangeMutation(e){return{typ:"change",iss:e.issuer,sub:a.from(e.docID).toString(),patch:e.patch,hlc:t.serialize(e.hlc.now()),...null!=e.cap?{cap:e.cap}:{}}}export function createRemoveMutation(e){return{typ:"change",iss:e.issuer,sub:e.docID,patch:[{op:"replace",path:"/",value:null}],hlc:t.serialize(e.hlc.now()),...null!=e.cap?{cap:e.cap}:{}}}
1
+ import { HLC } from '@kubun/hlc';
2
+ import { DocumentID } from '@kubun/id';
3
+ import { isValidAccessPermissions } from '@kubun/protocol';
4
+ import { toB64 } from '@sozai/codec';
5
+ import { InvalidAccessPermissionsError } from './errors.js';
6
+ const ACCESS_PERMISSIONS_FIELD = 'accessPermissions';
7
+ const ACCESS_PERMISSIONS_PATH = `/${ACCESS_PERMISSIONS_FIELD}`;
8
+ /**
9
+ * Reject a mint whose data carries a malformed reserved `accessPermissions`
10
+ * field. Absent is fine; present must be well-formed.
11
+ */ function assertSetAccessPermissions(data) {
12
+ if (ACCESS_PERMISSIONS_FIELD in data && !isValidAccessPermissions(data[ACCESS_PERMISSIONS_FIELD])) {
13
+ throw new InvalidAccessPermissionsError('set mutation data');
14
+ }
15
+ }
16
+ /**
17
+ * Reject a mint whose patch replaces the whole `accessPermissions` object with a
18
+ * malformed value. Only whole-object `add`/`replace` ops are checked — that is
19
+ * the shape every producer emits. Deeper sub-paths and removals are left to the
20
+ * tolerant apply path, which cannot mis-grant on garbage.
21
+ */ function assertPatchAccessPermissions(patch) {
22
+ for (const op of patch){
23
+ if (op.path === ACCESS_PERMISSIONS_PATH && (op.op === 'add' || op.op === 'replace') && !isValidAccessPermissions(op.value)) {
24
+ throw new InvalidAccessPermissionsError('change mutation patch');
25
+ }
26
+ }
27
+ }
28
+ export async function createSetMutation(params) {
29
+ assertSetAccessPermissions(params.data);
30
+ const issuer = params.issuer;
31
+ const owner = params.owner ?? issuer;
32
+ const docID = await DocumentID.create(params.modelID, owner, params.unique);
33
+ return {
34
+ typ: 'set',
35
+ iss: issuer,
36
+ aud: owner,
37
+ sub: docID.toString(),
38
+ data: params.data,
39
+ hlc: HLC.serialize(params.hlc.now()),
40
+ unq: toB64(params.unique),
41
+ ...params.cap != null ? {
42
+ cap: params.cap
43
+ } : {}
44
+ };
45
+ }
46
+ export function createChangeMutation(params) {
47
+ assertPatchAccessPermissions(params.patch);
48
+ return {
49
+ typ: 'change',
50
+ iss: params.issuer,
51
+ sub: DocumentID.from(params.docID).toString(),
52
+ patch: params.patch,
53
+ hlc: HLC.serialize(params.hlc.now()),
54
+ ...params.cap != null ? {
55
+ cap: params.cap
56
+ } : {}
57
+ };
58
+ }
59
+ export function createRemoveMutation(params) {
60
+ return {
61
+ typ: 'change',
62
+ iss: params.issuer,
63
+ sub: params.docID,
64
+ patch: [
65
+ {
66
+ op: 'replace',
67
+ path: '/',
68
+ value: null
69
+ }
70
+ ],
71
+ hlc: HLC.serialize(params.hlc.now()),
72
+ ...params.cap != null ? {
73
+ cap: params.cap
74
+ } : {}
75
+ };
76
+ }
package/lib/errors.d.ts CHANGED
@@ -5,7 +5,96 @@
5
5
  * structured wire response without losing the reason.
6
6
  */
7
7
  export declare class WriteAccessDeniedError extends Error {
8
- readonly code: "KB07";
9
- readonly docID: string;
8
+ #private;
10
9
  constructor(docID: string);
10
+ get code(): 'KB07';
11
+ get docID(): string;
11
12
  }
13
+ /**
14
+ * Thrown by the set apply path when the document ID in `sub` does not match the
15
+ * ID deterministically derived from the document's model, owner, and unique
16
+ * value. This binds owner↔ID so a writer cannot claim a document ID that does
17
+ * not hash from their own owner identity, preventing an attacker from squatting
18
+ * a victim's deterministic-unique document ID. Tagged with the protocol-level
19
+ * error code `KB09` so callers — particularly the RPC handler — can translate
20
+ * the throw into a structured wire response without losing the reason.
21
+ */
22
+ export declare class DocumentIDMismatchError extends Error {
23
+ #private;
24
+ constructor(docID: string);
25
+ get code(): 'KB09';
26
+ get docID(): string;
27
+ }
28
+ /**
29
+ * Thrown by the mutation apply path when an incoming mutation's HLC timestamp
30
+ * sits further in the future than the allowed drift bound. A signed timestamp
31
+ * is a fixed property of the mutation, so resending cannot move it back under
32
+ * the bound — the reject is permanent. Tagged `KB10` so the RPC handler can
33
+ * translate the throw into a structured wire response.
34
+ */
35
+ export declare class HLCDriftError extends Error {
36
+ #private;
37
+ constructor(driftMS: number, maxDriftMS: number);
38
+ get code(): 'KB10';
39
+ get driftMS(): number;
40
+ get maxDriftMS(): number;
41
+ }
42
+ /**
43
+ * Thrown by the set apply path when a mutation would reassign an existing
44
+ * document's owner. Ownership is immutable once a document exists, so this is a
45
+ * deterministic, permanent reject — the same mutation will always fail.
46
+ * Tagged `KB11` so the RPC handler can translate the throw into a structured
47
+ * wire response.
48
+ */
49
+ export declare class OwnerChangeError extends Error {
50
+ #private;
51
+ constructor(docID: string, fromOwner: string, toOwner: string);
52
+ get code(): 'KB11';
53
+ get docID(): string;
54
+ }
55
+ /**
56
+ * Thrown by the apply dispatcher when a mutation carries a type the engine does
57
+ * not support. The type is fixed in the signed mutation, so this is a
58
+ * deterministic, permanent reject. Tagged `KB12` so the RPC handler can
59
+ * translate the throw into a structured wire response.
60
+ */
61
+ export declare class UnsupportedMutationTypeError extends Error {
62
+ #private;
63
+ constructor(mutationType: string);
64
+ get code(): 'KB12';
65
+ get mutationType(): string;
66
+ }
67
+ /**
68
+ * Thrown by the apply path when a mutation's payload fails its model's schema
69
+ * validation. The payload is a fixed property of the signed mutation, so this
70
+ * is a deterministic, permanent reject. The original validation error is
71
+ * preserved as `cause`. Tagged `KB13` so the RPC handler can translate the
72
+ * throw into a structured wire response.
73
+ */
74
+ export declare class MutationSchemaValidationError extends Error {
75
+ #private;
76
+ constructor(docID: string, options?: {
77
+ cause?: unknown;
78
+ });
79
+ get code(): 'KB13';
80
+ get docID(): string;
81
+ }
82
+ /**
83
+ * Thrown at mutation-mint time when a `set`/`change` mutation would carry a
84
+ * malformed reserved `accessPermissions` field. Minting is strict so a producer
85
+ * bug is caught at signing; the receive/apply path stays tolerant (an already
86
+ * signed remote mutation is never re-rejected on this basis, so convergence is
87
+ * preserved). Not tagged with a KB apply code because it never reaches the apply
88
+ * path — it fails before the mutation is signed.
89
+ */
90
+ export declare class InvalidAccessPermissionsError extends Error {
91
+ constructor(detail?: string);
92
+ }
93
+ /**
94
+ * True when `err` carries a protocol error code identifying a deterministically
95
+ * permanent mutation reject. A permanent reject is one the sender cannot fix by
96
+ * resending (bad signature/access, malformed payload, immutable-property
97
+ * violation), so the apply path persists a `rejected` log row for it. Anything
98
+ * else — a transient store/db failure — is excluded so it stays retryable.
99
+ */
100
+ export declare function isPermanentApplyError(err: unknown): boolean;
package/lib/errors.js CHANGED
@@ -1 +1,159 @@
1
- export class WriteAccessDeniedError extends Error{code="KB07";docID;constructor(e){super(`Access denied: cannot write document ${e}`),this.name="WriteAccessDeniedError",this.docID=e}}
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
+ #docID;
8
+ constructor(docID){
9
+ super(`Access denied: cannot write document ${docID}`);
10
+ this.name = 'WriteAccessDeniedError';
11
+ this.#docID = docID;
12
+ }
13
+ get code() {
14
+ return 'KB07';
15
+ }
16
+ get docID() {
17
+ return this.#docID;
18
+ }
19
+ }
20
+ /**
21
+ * Thrown by the set apply path when the document ID in `sub` does not match the
22
+ * ID deterministically derived from the document's model, owner, and unique
23
+ * value. This binds owner↔ID so a writer cannot claim a document ID that does
24
+ * not hash from their own owner identity, preventing an attacker from squatting
25
+ * a victim's deterministic-unique document ID. Tagged with the protocol-level
26
+ * error code `KB09` so callers — particularly the RPC handler — can translate
27
+ * the throw into a structured wire response without losing the reason.
28
+ */ export class DocumentIDMismatchError extends Error {
29
+ #docID;
30
+ constructor(docID){
31
+ super(`Document ID mismatch: ${docID} is not bound to its owner and unique value`);
32
+ this.name = 'DocumentIDMismatchError';
33
+ this.#docID = docID;
34
+ }
35
+ get code() {
36
+ return 'KB09';
37
+ }
38
+ get docID() {
39
+ return this.#docID;
40
+ }
41
+ }
42
+ /**
43
+ * Thrown by the mutation apply path when an incoming mutation's HLC timestamp
44
+ * sits further in the future than the allowed drift bound. A signed timestamp
45
+ * is a fixed property of the mutation, so resending cannot move it back under
46
+ * the bound — the reject is permanent. Tagged `KB10` so the RPC handler can
47
+ * translate the throw into a structured wire response.
48
+ */ export class HLCDriftError extends Error {
49
+ #driftMS;
50
+ #maxDriftMS;
51
+ constructor(driftMS, maxDriftMS){
52
+ super(`HLC timestamp too far in the future: ${driftMS}ms drift exceeds ${maxDriftMS}ms limit`);
53
+ this.name = 'HLCDriftError';
54
+ this.#driftMS = driftMS;
55
+ this.#maxDriftMS = maxDriftMS;
56
+ }
57
+ get code() {
58
+ return 'KB10';
59
+ }
60
+ get driftMS() {
61
+ return this.#driftMS;
62
+ }
63
+ get maxDriftMS() {
64
+ return this.#maxDriftMS;
65
+ }
66
+ }
67
+ /**
68
+ * Thrown by the set apply path when a mutation would reassign an existing
69
+ * document's owner. Ownership is immutable once a document exists, so this is a
70
+ * deterministic, permanent reject — the same mutation will always fail.
71
+ * Tagged `KB11` so the RPC handler can translate the throw into a structured
72
+ * wire response.
73
+ */ export class OwnerChangeError extends Error {
74
+ #docID;
75
+ constructor(docID, fromOwner, toOwner){
76
+ super(`Cannot change owner from ${fromOwner} to ${toOwner} in document: ${docID}`);
77
+ this.name = 'OwnerChangeError';
78
+ this.#docID = docID;
79
+ }
80
+ get code() {
81
+ return 'KB11';
82
+ }
83
+ get docID() {
84
+ return this.#docID;
85
+ }
86
+ }
87
+ /**
88
+ * Thrown by the apply dispatcher when a mutation carries a type the engine does
89
+ * not support. The type is fixed in the signed mutation, so this is a
90
+ * deterministic, permanent reject. Tagged `KB12` so the RPC handler can
91
+ * translate the throw into a structured wire response.
92
+ */ export class UnsupportedMutationTypeError extends Error {
93
+ #mutationType;
94
+ constructor(mutationType){
95
+ super(`Unsupported mutation type: ${mutationType}`);
96
+ this.name = 'UnsupportedMutationTypeError';
97
+ this.#mutationType = mutationType;
98
+ }
99
+ get code() {
100
+ return 'KB12';
101
+ }
102
+ get mutationType() {
103
+ return this.#mutationType;
104
+ }
105
+ }
106
+ /**
107
+ * Thrown by the apply path when a mutation's payload fails its model's schema
108
+ * validation. The payload is a fixed property of the signed mutation, so this
109
+ * is a deterministic, permanent reject. The original validation error is
110
+ * preserved as `cause`. Tagged `KB13` so the RPC handler can translate the
111
+ * throw into a structured wire response.
112
+ */ export class MutationSchemaValidationError extends Error {
113
+ #docID;
114
+ constructor(docID, options){
115
+ super(`Mutation payload failed model schema validation for document ${docID}`, options);
116
+ this.name = 'MutationSchemaValidationError';
117
+ this.#docID = docID;
118
+ }
119
+ get code() {
120
+ return 'KB13';
121
+ }
122
+ get docID() {
123
+ return this.#docID;
124
+ }
125
+ }
126
+ /**
127
+ * Thrown at mutation-mint time when a `set`/`change` mutation would carry a
128
+ * malformed reserved `accessPermissions` field. Minting is strict so a producer
129
+ * bug is caught at signing; the receive/apply path stays tolerant (an already
130
+ * signed remote mutation is never re-rejected on this basis, so convergence is
131
+ * preserved). Not tagged with a KB apply code because it never reaches the apply
132
+ * path — it fails before the mutation is signed.
133
+ */ export class InvalidAccessPermissionsError extends Error {
134
+ constructor(detail){
135
+ super(`Invalid accessPermissions shape${detail != null ? `: ${detail}` : ''}`);
136
+ this.name = 'InvalidAccessPermissionsError';
137
+ }
138
+ }
139
+ const PERMANENT_APPLY_ERROR_CODES = new Set([
140
+ 'KB07',
141
+ 'KB09',
142
+ 'KB10',
143
+ 'KB11',
144
+ 'KB12',
145
+ 'KB13'
146
+ ]);
147
+ /**
148
+ * True when `err` carries a protocol error code identifying a deterministically
149
+ * permanent mutation reject. A permanent reject is one the sender cannot fix by
150
+ * resending (bad signature/access, malformed payload, immutable-property
151
+ * violation), so the apply path persists a `rejected` log row for it. Anything
152
+ * else — a transient store/db failure — is excluded so it stays retryable.
153
+ */ export function isPermanentApplyError(err) {
154
+ if (typeof err === 'object' && err != null && 'code' in err) {
155
+ const code = err.code;
156
+ return typeof code === 'string' && PERMANENT_APPLY_ERROR_CODES.has(code);
157
+ }
158
+ return false;
159
+ }
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, InvalidAccessPermissionsError, 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"@kubun/hlc";export{applyChangeMutation,applyMutation,applySetMutation,getTopLevelField}from"./apply.js";export{createChangeMutation,createRemoveMutation,createSetMutation}from"./create.js";export{WriteAccessDeniedError}from"./errors.js";export{convertPatchInput,createMutationOperations}from"./operations.js";
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, InvalidAccessPermissionsError, isPermanentApplyError, MutationSchemaValidationError, OwnerChangeError, UnsupportedMutationTypeError, WriteAccessDeniedError } from './errors.js';
5
+ export { convertPatchInput, createMutationOperations } from './operations.js';
@@ -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 as t,createRemoveMutation as e,createSetMutation as r}from"./create.js";export function createMutationOperations(n){let{issuer:a,hlc:c,getRandomValues:u,cap:o,processSetMutation:i,processChangeMutation:m}=n;return{async createDocument({modelID:t,data:e,transactionID:n}){let m=u(new Uint8Array(12)),s=await r({issuer:a,hlc:c,modelID:t,data:e,unique:m,cap:o});return await i(s,n)},async setDocument({modelID:t,unique:e,data:n,transactionID:u}){let m=await r({issuer:a,hlc:c,modelID:t,data:n,unique:e,cap:o});return await i(m,u)},async updateDocument({docID:e,patch:r,transactionID:n}){let u=t({issuer:a,hlc:c,docID:e,patch:r,cap:o});return await m(u,n)},async removeDocument({docID:t,transactionID:r}){let n=e({docID:t,issuer:a,hlc:c,cap:o});return await m(n,r)}}}export function convertPatchInput(t){return t.map(t=>{let[e,r]=Object.entries(t)[0];return{...r,op:e}})}
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,39 +1,39 @@
1
1
  {
2
2
  "name": "@kubun/mutation",
3
- "version": "0.10.1",
4
- "license": "see LICENSE.md",
3
+ "version": "0.12.0",
5
4
  "keywords": [],
5
+ "license": "see LICENSE.md",
6
+ "sideEffects": false,
6
7
  "type": "module",
7
- "main": "lib/index.js",
8
- "types": "lib/index.d.ts",
9
8
  "exports": {
10
9
  ".": "./lib/index.js"
11
10
  },
11
+ "main": "lib/index.js",
12
+ "types": "lib/index.d.ts",
12
13
  "files": [
13
14
  "lib/*",
14
15
  "LICENSE.md"
15
16
  ],
16
- "sideEffects": false,
17
17
  "dependencies": {
18
- "@enkaku/codec": "^0.16.0",
19
- "@enkaku/patch": "^0.16.0",
20
- "@enkaku/runtime": "^0.16.0",
21
- "@enkaku/schema": "^0.16.0",
22
- "@kubun/hlc": "^0.10.1",
23
- "@kubun/id": "^0.10.0"
18
+ "@sozai/codec": "^0.4.0",
19
+ "@sozai/patch": "^0.1.3",
20
+ "@sozai/runtime": "^0.1.0",
21
+ "@sozai/schema": "^0.1.1",
22
+ "@kubun/hlc": "^0.12.0",
23
+ "@kubun/id": "^0.12.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@kubun/protocol": "^0.10.0",
27
- "@kubun/graphql": "^0.10.1"
26
+ "@kubun/graphql": "^0.12.0",
27
+ "@kubun/protocol": "^0.12.0"
28
28
  },
29
29
  "scripts": {
30
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
30
31
  "build:clean": "del lib",
31
- "build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
32
+ "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
32
33
  "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
33
34
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
34
- "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
35
+ "test": "pnpm run test:types && pnpm run test:unit",
35
36
  "test:types": "tsc --noEmit -p tsconfig.test.json",
36
- "test:unit": "vitest run",
37
- "test": "pnpm run test:types && pnpm run test:unit"
37
+ "test:unit": "vitest run"
38
38
  }
39
39
  }