@kubun/mutation 0.10.0 → 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 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,333 @@
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,a){let r=l.parse(t),o=Date.now(),n=r.wallTime-o;if(n>e)throw Error(`HLC timestamp too far in the future: ${n}ms drift exceeds ${e}ms limit`);a&&a.receive(r)}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,t.hlc);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,e.hlc);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 { 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{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 { 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
- 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
+ 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"@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, 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/mutation",
3
- "version": "0.10.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
- "@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.0",
23
- "@kubun/id": "^0.10.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/graphql": "^0.10.0",
27
- "@kubun/protocol": "^0.10.0"
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",