@kubun/mutation 0.11.0 → 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.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { HLC } from '@kubun/hlc';
2
2
  import { DocumentID } from '@kubun/id';
3
+ import { isValidAccessRule, RESERVED_DOCUMENT_FIELDS } from '@kubun/protocol';
3
4
  import { fromB64 } from '@sozai/codec';
4
5
  import { applyPatches } from '@sozai/patch';
5
6
  import { asType, createValidator } from '@sozai/schema';
@@ -36,6 +37,77 @@ export function getTopLevelField(path) {
36
37
  const secondSlash = path.indexOf('/', 1);
37
38
  return secondSlash === -1 ? path : path.slice(0, secondSlash);
38
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
+ }
39
111
  /**
40
112
  * Compute the visible document data from the per-field winning values and the
41
113
  * reserved set/delete timestamps. A document is alive when it carries a set
@@ -163,16 +235,30 @@ export async function applyChangeMutation(ctx, mutation) {
163
235
  }
164
236
  const cloned = structuredClone(base);
165
237
  applyPatches(cloned, winningPatches, false);
166
- let validated;
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;
167
243
  try {
168
- validated = asType(validator, cloned);
244
+ validatedModel = asType(validator, modelFields);
169
245
  } catch (err) {
170
246
  throw new MutationSchemaValidationError(docID, {
171
247
  cause: err
172
248
  });
173
249
  }
250
+ const validated = {
251
+ ...validatedModel,
252
+ ...reserved
253
+ };
174
254
  for (const path of winningFields){
175
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
+ }
176
262
  fieldValues[path] = validated[field];
177
263
  fieldHLCs[path] = mutation.hlc;
178
264
  }
@@ -219,9 +305,16 @@ export async function applySetMutation(ctx, mutation) {
219
305
  ctx.store.getFieldHLCs(docID),
220
306
  ctx.store.getFieldValues(docID)
221
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);
222
312
  let data;
223
313
  try {
224
- data = asType(validator, mutation.data);
314
+ data = {
315
+ ...asType(validator, modelData),
316
+ ...reserved
317
+ };
225
318
  } catch (err) {
226
319
  throw new MutationSchemaValidationError(docID, {
227
320
  cause: err
package/lib/create.js CHANGED
@@ -1,7 +1,32 @@
1
1
  import { HLC } from '@kubun/hlc';
2
2
  import { DocumentID } from '@kubun/id';
3
+ import { isValidAccessPermissions } from '@kubun/protocol';
3
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
+ }
4
28
  export async function createSetMutation(params) {
29
+ assertSetAccessPermissions(params.data);
5
30
  const issuer = params.issuer;
6
31
  const owner = params.owner ?? issuer;
7
32
  const docID = await DocumentID.create(params.modelID, owner, params.unique);
@@ -19,6 +44,7 @@ export async function createSetMutation(params) {
19
44
  };
20
45
  }
21
46
  export function createChangeMutation(params) {
47
+ assertPatchAccessPermissions(params.patch);
22
48
  return {
23
49
  typ: 'change',
24
50
  iss: params.issuer,
package/lib/errors.d.ts CHANGED
@@ -5,9 +5,10 @@
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
  }
12
13
  /**
13
14
  * Thrown by the set apply path when the document ID in `sub` does not match the
@@ -19,9 +20,10 @@ export declare class WriteAccessDeniedError extends Error {
19
20
  * the throw into a structured wire response without losing the reason.
20
21
  */
21
22
  export declare class DocumentIDMismatchError extends Error {
22
- readonly code: "KB09";
23
- readonly docID: string;
23
+ #private;
24
24
  constructor(docID: string);
25
+ get code(): 'KB09';
26
+ get docID(): string;
25
27
  }
26
28
  /**
27
29
  * Thrown by the mutation apply path when an incoming mutation's HLC timestamp
@@ -77,6 +79,17 @@ export declare class MutationSchemaValidationError extends Error {
77
79
  get code(): 'KB13';
78
80
  get docID(): string;
79
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
+ }
80
93
  /**
81
94
  * True when `err` carries a protocol error code identifying a deterministically
82
95
  * permanent mutation reject. A permanent reject is one the sender cannot fix by
package/lib/errors.js CHANGED
@@ -4,12 +4,17 @@
4
4
  * — particularly the RPC handler — can translate the throw into a
5
5
  * structured wire response without losing the reason.
6
6
  */ export class WriteAccessDeniedError extends Error {
7
- code = 'KB07';
8
- docID;
7
+ #docID;
9
8
  constructor(docID){
10
9
  super(`Access denied: cannot write document ${docID}`);
11
10
  this.name = 'WriteAccessDeniedError';
12
- this.docID = docID;
11
+ this.#docID = docID;
12
+ }
13
+ get code() {
14
+ return 'KB07';
15
+ }
16
+ get docID() {
17
+ return this.#docID;
13
18
  }
14
19
  }
15
20
  /**
@@ -21,12 +26,17 @@
21
26
  * error code `KB09` so callers — particularly the RPC handler — can translate
22
27
  * the throw into a structured wire response without losing the reason.
23
28
  */ export class DocumentIDMismatchError extends Error {
24
- code = 'KB09';
25
- docID;
29
+ #docID;
26
30
  constructor(docID){
27
31
  super(`Document ID mismatch: ${docID} is not bound to its owner and unique value`);
28
32
  this.name = 'DocumentIDMismatchError';
29
- this.docID = docID;
33
+ this.#docID = docID;
34
+ }
35
+ get code() {
36
+ return 'KB09';
37
+ }
38
+ get docID() {
39
+ return this.#docID;
30
40
  }
31
41
  }
32
42
  /**
@@ -113,6 +123,19 @@
113
123
  return this.#docID;
114
124
  }
115
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
+ }
116
139
  const PERMANENT_APPLY_ERROR_CODES = new Set([
117
140
  'KB07',
118
141
  'KB09',
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 { DocumentIDMismatchError, HLCDriftError, isPermanentApplyError, MutationSchemaValidationError, OwnerChangeError, UnsupportedMutationTypeError, 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,5 +1,5 @@
1
1
  export { HLC } from '@kubun/hlc';
2
2
  export { applyChangeMutation, applyMutation, applySetMutation, getTopLevelField } from './apply.js';
3
3
  export { createChangeMutation, createRemoveMutation, createSetMutation } from './create.js';
4
- export { DocumentIDMismatchError, HLCDriftError, isPermanentApplyError, MutationSchemaValidationError, OwnerChangeError, UnsupportedMutationTypeError, WriteAccessDeniedError } from './errors.js';
4
+ export { DocumentIDMismatchError, HLCDriftError, InvalidAccessPermissionsError, isPermanentApplyError, MutationSchemaValidationError, OwnerChangeError, UnsupportedMutationTypeError, WriteAccessDeniedError } from './errors.js';
5
5
  export { convertPatchInput, createMutationOperations } from './operations.js';
package/package.json CHANGED
@@ -1,39 +1,39 @@
1
1
  {
2
2
  "name": "@kubun/mutation",
3
- "version": "0.11.0",
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
- "@sozai/codec": "^0.1.0",
19
- "@sozai/patch": "^0.1.1",
18
+ "@sozai/codec": "^0.4.0",
19
+ "@sozai/patch": "^0.1.3",
20
20
  "@sozai/runtime": "^0.1.0",
21
- "@sozai/schema": "^0.1.0",
22
- "@kubun/hlc": "^0.11.0",
23
- "@kubun/id": "^0.11.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/graphql": "^0.11.0",
27
- "@kubun/protocol": "^0.11.0"
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
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
  }