@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 +96 -3
- package/lib/create.js +26 -0
- package/lib/errors.d.ts +17 -4
- package/lib/errors.js +29 -6
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/package.json +15 -15
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
docID;
|
|
7
|
+
#docID;
|
|
9
8
|
constructor(docID){
|
|
10
9
|
super(`Access denied: cannot write document ${docID}`);
|
|
11
10
|
this.name = 'WriteAccessDeniedError';
|
|
12
|
-
this
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
19
|
-
"@sozai/patch": "^0.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.
|
|
22
|
-
"@kubun/hlc": "^0.
|
|
23
|
-
"@kubun/id": "^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.
|
|
27
|
-
"@kubun/protocol": "^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
|
-
"
|
|
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
|
}
|