@kubun/engine 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/engine-events.d.ts +24 -0
- package/lib/engine.d.ts +1 -1
- package/lib/engine.js +416 -50
- package/lib/errors.d.ts +5 -1
- package/lib/errors.js +20 -2
- package/lib/plugin.d.ts +19 -0
- package/package.json +29 -29
package/lib/engine-events.d.ts
CHANGED
|
@@ -51,4 +51,28 @@ export type EngineEvents = {
|
|
|
51
51
|
/** Document model ID. */
|
|
52
52
|
modelID: string;
|
|
53
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* Emitted when this peer sets its own model access-default for a
|
|
56
|
+
* (modelID, permissionType). Carries the full rule so a subscriber can
|
|
57
|
+
* replicate the owner's sharing policy to co-members without re-reading it.
|
|
58
|
+
*/
|
|
59
|
+
'engine:access-default:set': {
|
|
60
|
+
ownerDID: string;
|
|
61
|
+
modelID: string;
|
|
62
|
+
permissionType: 'read' | 'write';
|
|
63
|
+
accessLevel: string;
|
|
64
|
+
allowedDIDs: Array<string> | null;
|
|
65
|
+
allowedCircles: Array<string> | null;
|
|
66
|
+
allowedGroups: Array<string> | null;
|
|
67
|
+
/** LWW anchor stamped by this peer. */
|
|
68
|
+
hlc: string;
|
|
69
|
+
};
|
|
70
|
+
/** Emitted when this peer removes its own model access-default(s). */
|
|
71
|
+
'engine:access-default:removed': {
|
|
72
|
+
ownerDID: string;
|
|
73
|
+
modelID: string;
|
|
74
|
+
permissionTypes: Array<'read' | 'write'>;
|
|
75
|
+
/** LWW anchor stamped by this peer. */
|
|
76
|
+
hlc: string;
|
|
77
|
+
};
|
|
54
78
|
};
|
package/lib/engine.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { VerifyTokenHook } from '@kokuin/capability';
|
|
2
2
|
import type { Identity } from '@kokuin/token';
|
|
3
3
|
import { KubunDB, type StoreProvider } from '@kubun/db';
|
|
4
4
|
import type { Adapter } from '@kubun/db-adapter';
|
package/lib/engine.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createRevocationChecker } from '@kokuin/capability';
|
|
2
1
|
import { isOwnIdentity, isSigningIdentity, stringifyToken, verifyToken } from '@kokuin/token';
|
|
3
2
|
import { KubunDB } from '@kubun/db';
|
|
4
3
|
import { createReadContext, createSchema } from '@kubun/graphql';
|
|
@@ -7,7 +6,7 @@ import { DocumentID } from '@kubun/id';
|
|
|
7
6
|
import { getKubunLogger } from '@kubun/logger';
|
|
8
7
|
import { applyMutation, convertPatchInput, createMutationOperations, WriteAccessDeniedError } from '@kubun/mutation';
|
|
9
8
|
import { clusterToRecord, documentMutation, GraphModel } from '@kubun/protocol';
|
|
10
|
-
import {
|
|
9
|
+
import { createDelegationRevocationChecker, delegationStoreDefinition, getDelegationStore } from '@kubun/store-delegation';
|
|
11
10
|
import { GRAPH_STORE, getGraphStore, graphStoreDefinition } from '@kubun/store-graph';
|
|
12
11
|
import { getP2PStore, P2P_STORE } from '@kubun/store-p2p';
|
|
13
12
|
import { createRuntime } from '@sozai/runtime';
|
|
@@ -124,6 +123,28 @@ function accessControlNotSupported() {
|
|
|
124
123
|
function transactionNotSupported() {
|
|
125
124
|
throw new Error('Transaction mutations are not supported by the engine core — plugin override required (see plugin-rpc).');
|
|
126
125
|
}
|
|
126
|
+
const ACCESS_LEVELS = new Set([
|
|
127
|
+
'only_owner',
|
|
128
|
+
'anyone',
|
|
129
|
+
'restricted'
|
|
130
|
+
]);
|
|
131
|
+
function assertAccessLevel(value) {
|
|
132
|
+
if (!ACCESS_LEVELS.has(value)) {
|
|
133
|
+
throw new Error(`Invalid access level: ${value}`);
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
function assertPermissionType(value) {
|
|
138
|
+
if (value !== 'read' && value !== 'write') {
|
|
139
|
+
throw new Error(`Invalid permission type: ${value}`);
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
/** Extract a document's stored `accessPermissions` object, or undefined. */ function readAccessPermissions(data) {
|
|
144
|
+
if (data == null) return undefined;
|
|
145
|
+
const perms = data.accessPermissions;
|
|
146
|
+
return perms != null && typeof perms === 'object' ? perms : undefined;
|
|
147
|
+
}
|
|
127
148
|
const baseMutationStubs = {
|
|
128
149
|
executeCreateMutation: graphMutationNotSupported,
|
|
129
150
|
executeSetMutation: graphMutationNotSupported,
|
|
@@ -181,6 +202,13 @@ export class KubunEngine {
|
|
|
181
202
|
#maxSubscriptionQueueSize;
|
|
182
203
|
#logger;
|
|
183
204
|
#mergedPolicies = {};
|
|
205
|
+
/**
|
|
206
|
+
* Mutation field names declared non-transactional by a plugin schema
|
|
207
|
+
* extension. Populated as extensions are instantiated (schema build / deploy)
|
|
208
|
+
* and consulted by `mutateGraph` to decide whether to open the write
|
|
209
|
+
* transaction. Engine-wide: a field name marked here is non-transactional
|
|
210
|
+
* wherever it appears.
|
|
211
|
+
*/ #nonTransactionalMutationFields = new Set();
|
|
184
212
|
#pluginExtensions = new Map();
|
|
185
213
|
#plugins = [];
|
|
186
214
|
#registry;
|
|
@@ -447,7 +475,7 @@ export class KubunEngine {
|
|
|
447
475
|
// with no p2p store still holds delegation/revocation rows and must honor
|
|
448
476
|
// them when validating a delegated read chain.
|
|
449
477
|
const delegationStore = await getDelegationStore(provider);
|
|
450
|
-
const revocationChecker =
|
|
478
|
+
const revocationChecker = createDelegationRevocationChecker(delegationStore);
|
|
451
479
|
let accessControlDB;
|
|
452
480
|
let viewerReadAccess;
|
|
453
481
|
if (p2pStore != null) {
|
|
@@ -551,6 +579,9 @@ export class KubunEngine {
|
|
|
551
579
|
const extensionFn = this.#pluginExtensions.get(pluginName);
|
|
552
580
|
if (extensionFn != null) {
|
|
553
581
|
const ext = extensionFn(config);
|
|
582
|
+
for (const field of ext.nonTransactionalMutationFields ?? []){
|
|
583
|
+
this.#nonTransactionalMutationFields.add(field);
|
|
584
|
+
}
|
|
554
585
|
Object.assign(qf, ext.resolvers.queryFields ?? {});
|
|
555
586
|
Object.assign(mf, ext.resolvers.mutationFields ?? {});
|
|
556
587
|
Object.assign(sf, ext.resolvers.subscriptionFields ?? {});
|
|
@@ -697,6 +728,9 @@ export class KubunEngine {
|
|
|
697
728
|
throw new Error(`Plugin "${pluginName}" not found or does not provide schema extensions`);
|
|
698
729
|
}
|
|
699
730
|
const ext = extensionFn(config);
|
|
731
|
+
for (const field of ext.nonTransactionalMutationFields ?? []){
|
|
732
|
+
this.#nonTransactionalMutationFields.add(field);
|
|
733
|
+
}
|
|
700
734
|
sdlParts.push(ext.sdl);
|
|
701
735
|
}
|
|
702
736
|
if (sdlParts.length > 0) {
|
|
@@ -704,12 +738,24 @@ export class KubunEngine {
|
|
|
704
738
|
}
|
|
705
739
|
}
|
|
706
740
|
const store = await this.#getGraphStore();
|
|
741
|
+
// The clusters ride the graph's own transaction rather than following it in
|
|
742
|
+
// a second pass. `createGraph` stores models one by one and loses the
|
|
743
|
+
// grouping, and the cluster is what lets a peer ship a model's definition to
|
|
744
|
+
// a device that lacks it — so a failure between the two writes leaves a graph
|
|
745
|
+
// that works locally and cannot be synced to a device that has never seen it.
|
|
707
746
|
const id = await store.createGraph({
|
|
708
747
|
id: params.id ?? this.#runtime.getRandomID(),
|
|
709
748
|
name: params.name,
|
|
710
749
|
record: model.record,
|
|
711
750
|
extensionSDL,
|
|
712
|
-
pluginConfig
|
|
751
|
+
pluginConfig,
|
|
752
|
+
clusters: Object.fromEntries(Object.entries(clustersRecord).map(([clusterID, cluster])=>[
|
|
753
|
+
clusterID,
|
|
754
|
+
{
|
|
755
|
+
definition: cluster,
|
|
756
|
+
models: cluster.record
|
|
757
|
+
}
|
|
758
|
+
]))
|
|
713
759
|
});
|
|
714
760
|
// The store write is additive (it accumulates models across deploys), so the
|
|
715
761
|
// in-memory model and schema must reflect the full merged graph, not just
|
|
@@ -931,7 +977,7 @@ export class KubunEngine {
|
|
|
931
977
|
if (held.length > 0) {
|
|
932
978
|
extraTokensByIssuer.set(mutation.iss, held.map((row)=>row.token));
|
|
933
979
|
}
|
|
934
|
-
const revocationChecker =
|
|
980
|
+
const revocationChecker = createDelegationRevocationChecker(delegationStore);
|
|
935
981
|
// The MLS/membership gate is the only piece that needs the p2p store. Gate
|
|
936
982
|
// on store registration, not on whether the lookup throws. An unregistered
|
|
937
983
|
// p2p store is a light client with no membership to enforce, so the gate is
|
|
@@ -1124,7 +1170,7 @@ export class KubunEngine {
|
|
|
1124
1170
|
// the transaction provider `tx` so the lookups run inside this batch's
|
|
1125
1171
|
// transaction.
|
|
1126
1172
|
const delegationStore = await getDelegationStore(tx);
|
|
1127
|
-
const revocationChecker =
|
|
1173
|
+
const revocationChecker = createDelegationRevocationChecker(delegationStore);
|
|
1128
1174
|
for (const iss of new Set(verified.map((v)=>v.mutation.iss))){
|
|
1129
1175
|
const minSigningTime = issuerAtTimes.get(iss);
|
|
1130
1176
|
const includeFallback = issuerHasMissingHLC.has(iss) || minSigningTime == null;
|
|
@@ -1277,6 +1323,29 @@ export class KubunEngine {
|
|
|
1277
1323
|
});
|
|
1278
1324
|
}
|
|
1279
1325
|
/**
|
|
1326
|
+
* The `cap` array a signed write carries: the caller-supplied delegation
|
|
1327
|
+
* tokens plus every token this device holds for `audience` that is unexpired
|
|
1328
|
+
* right now. Auto-attaching the held ones is what lets a delegate write to
|
|
1329
|
+
* documents it does not own without the app passing tokens manually.
|
|
1330
|
+
* Delegation storage is core-registered, so the store is always present.
|
|
1331
|
+
* Returns `undefined` when there is nothing to attach.
|
|
1332
|
+
*
|
|
1333
|
+
* `provider` must be the provider the write itself runs against — the ambient
|
|
1334
|
+
* write transaction where one is open, the root provider otherwise.
|
|
1335
|
+
*/ async #collectWriteCapabilities(params) {
|
|
1336
|
+
const atTime = Math.floor(Date.now() / 1000);
|
|
1337
|
+
const capTokens = new Set(params.delegationTokens ?? []);
|
|
1338
|
+
const delegationStore = await getDelegationStore(params.provider);
|
|
1339
|
+
const held = await delegationStore.getHeldTokens({
|
|
1340
|
+
audience: params.audience,
|
|
1341
|
+
atTime
|
|
1342
|
+
});
|
|
1343
|
+
for (const row of held){
|
|
1344
|
+
capTokens.add(row.token);
|
|
1345
|
+
}
|
|
1346
|
+
return capTokens.size > 0 ? Array.from(capTokens) : undefined;
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1280
1349
|
* Shared machinery for a signed write transaction: opens a write transaction,
|
|
1281
1350
|
* builds a `signAndApply` choke point that signs each mutation with this
|
|
1282
1351
|
* engine's identity and applies it, auto-attaches held delegation tokens as
|
|
@@ -1322,23 +1391,14 @@ export class KubunEngine {
|
|
|
1322
1391
|
}
|
|
1323
1392
|
return applied.document;
|
|
1324
1393
|
};
|
|
1325
|
-
//
|
|
1326
|
-
//
|
|
1327
|
-
//
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
// single-connection SQLite.
|
|
1331
|
-
const atTime = Math.floor(Date.now() / 1000);
|
|
1332
|
-
const capTokens = new Set(params.delegationTokens ?? []);
|
|
1333
|
-
const delegationStore = await getDelegationStore(tx);
|
|
1334
|
-
const held = await delegationStore.getHeldTokens({
|
|
1394
|
+
// Read through the transaction provider `tx`, not the main connection:
|
|
1395
|
+
// querying the main connection while this write transaction is open
|
|
1396
|
+
// deadlocks on single-connection SQLite.
|
|
1397
|
+
const cap = await this.#collectWriteCapabilities({
|
|
1398
|
+
provider: tx,
|
|
1335
1399
|
audience: signingIdentity.id,
|
|
1336
|
-
|
|
1400
|
+
delegationTokens: params.delegationTokens
|
|
1337
1401
|
});
|
|
1338
|
-
for (const row of held){
|
|
1339
|
-
capTokens.add(row.token);
|
|
1340
|
-
}
|
|
1341
|
-
const cap = capTokens.size > 0 ? Array.from(capTokens) : undefined;
|
|
1342
1402
|
// Floor the clock to the stored mutation maximum before any local mint.
|
|
1343
1403
|
// `createMutationOperations` calls `hlc.now()` while building each
|
|
1344
1404
|
// mutation below, so the floor must complete first for a post-restart
|
|
@@ -1377,6 +1437,300 @@ export class KubunEngine {
|
|
|
1377
1437
|
await this.#emitMutationEvents(mutationResults, 'local');
|
|
1378
1438
|
return result;
|
|
1379
1439
|
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Collect the top-level field names selected by a mutation operation. Used to
|
|
1442
|
+
* route the operation onto the transactional or non-transactional mutation
|
|
1443
|
+
* path. A non-mutation operation yields no names.
|
|
1444
|
+
*
|
|
1445
|
+
* The operation text can come from any RPC client, so fragment spreads and
|
|
1446
|
+
* inline fragments at the mutation root are resolved rather than skipped: a
|
|
1447
|
+
* side-effecting field reachable only through `... on Mutation { ... }` or a
|
|
1448
|
+
* named fragment must still route onto the non-transactional path, otherwise
|
|
1449
|
+
* its network I/O runs inside the write transaction and a nested apply
|
|
1450
|
+
* deadlocks on single-connection SQLite. This runs before GraphQL validation,
|
|
1451
|
+
* so a missing or cyclic fragment must not throw or recurse forever.
|
|
1452
|
+
*/ #getRootMutationFieldNames(document) {
|
|
1453
|
+
const operation = getOperationDefinition(document);
|
|
1454
|
+
if (operation == null || operation.operation !== 'mutation') {
|
|
1455
|
+
return [];
|
|
1456
|
+
}
|
|
1457
|
+
const fragments = new Map();
|
|
1458
|
+
for (const definition of document.definitions){
|
|
1459
|
+
if (definition.kind === Kind.FRAGMENT_DEFINITION && !fragments.has(definition.name.value)) {
|
|
1460
|
+
// A duplicate fragment name is invalid GraphQL and rejected downstream;
|
|
1461
|
+
// keeping the first makes collection deterministic until then.
|
|
1462
|
+
fragments.set(definition.name.value, definition);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
const names = [];
|
|
1466
|
+
const expanded = new Set();
|
|
1467
|
+
const collect = (selectionSet)=>{
|
|
1468
|
+
for (const selection of selectionSet.selections){
|
|
1469
|
+
switch(selection.kind){
|
|
1470
|
+
case Kind.FIELD:
|
|
1471
|
+
// Introspection meta-fields are not mutation fields: `__typename` at
|
|
1472
|
+
// the mutation root resolves to a string and mutates nothing, so
|
|
1473
|
+
// counting it makes a lone non-transactional field look like a
|
|
1474
|
+
// mixed operation and rejects it. Latent only because Apollo adds
|
|
1475
|
+
// `__typename` inside selection sets and not beside the root field.
|
|
1476
|
+
if (!selection.name.value.startsWith('__')) {
|
|
1477
|
+
names.push(selection.name.value);
|
|
1478
|
+
}
|
|
1479
|
+
break;
|
|
1480
|
+
case Kind.INLINE_FRAGMENT:
|
|
1481
|
+
collect(selection.selectionSet);
|
|
1482
|
+
break;
|
|
1483
|
+
case Kind.FRAGMENT_SPREAD:
|
|
1484
|
+
{
|
|
1485
|
+
const name = selection.name.value;
|
|
1486
|
+
if (expanded.has(name)) {
|
|
1487
|
+
break;
|
|
1488
|
+
}
|
|
1489
|
+
expanded.add(name);
|
|
1490
|
+
const fragment = fragments.get(name);
|
|
1491
|
+
if (fragment != null) {
|
|
1492
|
+
collect(fragment.selectionSet);
|
|
1493
|
+
}
|
|
1494
|
+
break;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1499
|
+
collect(operation.selectionSet);
|
|
1500
|
+
return names;
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* Build the signed mutation operations exposed to a `mutateGraph` execution as
|
|
1504
|
+
* `contextExtensions`. Shared by the transactional and non-transactional
|
|
1505
|
+
* paths: `ops` and `provider` determine the transaction boundary (an ambient
|
|
1506
|
+
* write transaction, or per-op autocommit), while `onAccessDefault` decides
|
|
1507
|
+
* when access-default replication events fire (deferred to post-commit, or
|
|
1508
|
+
* immediately). The signing identity and authorization are identical on both
|
|
1509
|
+
* paths — only the transaction differs.
|
|
1510
|
+
*/ #buildGraphMutationContextExtensions(params) {
|
|
1511
|
+
const { ops, provider, signingIdentity, onAccessDefault } = params;
|
|
1512
|
+
return {
|
|
1513
|
+
executeCreateMutation: async (p)=>{
|
|
1514
|
+
return await ops.createDocument({
|
|
1515
|
+
modelID: p.modelID,
|
|
1516
|
+
data: p.data,
|
|
1517
|
+
owner: p.owner
|
|
1518
|
+
});
|
|
1519
|
+
},
|
|
1520
|
+
executeSetMutation: async (p)=>{
|
|
1521
|
+
return await ops.setDocument({
|
|
1522
|
+
modelID: p.modelID,
|
|
1523
|
+
unique: p.unique,
|
|
1524
|
+
data: p.data,
|
|
1525
|
+
owner: p.owner
|
|
1526
|
+
});
|
|
1527
|
+
},
|
|
1528
|
+
executeUpdateMutation: async (p)=>{
|
|
1529
|
+
return await ops.updateDocument({
|
|
1530
|
+
docID: p.input.id,
|
|
1531
|
+
patch: convertPatchInput(p.input.patch)
|
|
1532
|
+
});
|
|
1533
|
+
},
|
|
1534
|
+
executeRemoveMutation: async (p)=>{
|
|
1535
|
+
await ops.removeDocument({
|
|
1536
|
+
docID: p.id
|
|
1537
|
+
});
|
|
1538
|
+
},
|
|
1539
|
+
executeSetModelAccessDefaults: async (p)=>{
|
|
1540
|
+
const permissionType = assertPermissionType(p.permissionType);
|
|
1541
|
+
const accessLevel = assertAccessLevel(p.accessLevel);
|
|
1542
|
+
// A device states only its own policy — the owner is always the
|
|
1543
|
+
// signer, never a foreign DID from the request.
|
|
1544
|
+
const ownerDID = signingIdentity.id;
|
|
1545
|
+
const hlc = HLC.serialize(this.#hlc.now());
|
|
1546
|
+
const store = await getGraphStore(provider);
|
|
1547
|
+
await store.setUserModelAccessDefault({
|
|
1548
|
+
ownerDID,
|
|
1549
|
+
modelID: p.modelID,
|
|
1550
|
+
permissionType,
|
|
1551
|
+
accessLevel,
|
|
1552
|
+
allowedDIDs: p.allowedDIDs,
|
|
1553
|
+
allowedCircles: p.allowedCircles,
|
|
1554
|
+
allowedGroups: p.allowedGroups,
|
|
1555
|
+
hlc
|
|
1556
|
+
});
|
|
1557
|
+
await onAccessDefault({
|
|
1558
|
+
name: 'engine:access-default:set',
|
|
1559
|
+
data: {
|
|
1560
|
+
ownerDID,
|
|
1561
|
+
modelID: p.modelID,
|
|
1562
|
+
permissionType,
|
|
1563
|
+
accessLevel,
|
|
1564
|
+
allowedDIDs: p.allowedDIDs,
|
|
1565
|
+
allowedCircles: p.allowedCircles,
|
|
1566
|
+
allowedGroups: p.allowedGroups,
|
|
1567
|
+
hlc
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
const [read, write] = await Promise.all([
|
|
1571
|
+
store.getUserModelAccessDefault(ownerDID, p.modelID, 'read'),
|
|
1572
|
+
store.getUserModelAccessDefault(ownerDID, p.modelID, 'write')
|
|
1573
|
+
]);
|
|
1574
|
+
const permissions = {};
|
|
1575
|
+
if (read != null) permissions.read = read;
|
|
1576
|
+
if (write != null) permissions.write = write;
|
|
1577
|
+
return {
|
|
1578
|
+
ownerDID,
|
|
1579
|
+
modelID: p.modelID,
|
|
1580
|
+
permissions
|
|
1581
|
+
};
|
|
1582
|
+
},
|
|
1583
|
+
executeRemoveModelAccessDefaults: async (modelID, permissionTypes)=>{
|
|
1584
|
+
const types = permissionTypes.map(assertPermissionType);
|
|
1585
|
+
// One HLC stamps both the retained tombstone (the LWW anchor) and
|
|
1586
|
+
// the replication event, so co-members reject a stale set by the
|
|
1587
|
+
// same clock the local row carries.
|
|
1588
|
+
const hlc = HLC.serialize(this.#hlc.now());
|
|
1589
|
+
const store = await getGraphStore(provider);
|
|
1590
|
+
await store.removeUserModelAccessDefaults(signingIdentity.id, modelID, types, hlc);
|
|
1591
|
+
await onAccessDefault({
|
|
1592
|
+
name: 'engine:access-default:removed',
|
|
1593
|
+
data: {
|
|
1594
|
+
ownerDID: signingIdentity.id,
|
|
1595
|
+
modelID,
|
|
1596
|
+
permissionTypes: types,
|
|
1597
|
+
hlc
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
},
|
|
1601
|
+
executeSetDocumentAccessOverride: async (p)=>{
|
|
1602
|
+
const permissionType = assertPermissionType(p.permissionType);
|
|
1603
|
+
const accessLevel = assertAccessLevel(p.accessLevel);
|
|
1604
|
+
const store = await getGraphStore(provider);
|
|
1605
|
+
const existing = await store.getDocument(DocumentID.fromString(p.documentID));
|
|
1606
|
+
if (existing == null) {
|
|
1607
|
+
throw new Error(`Document not found: ${p.documentID}`);
|
|
1608
|
+
}
|
|
1609
|
+
const current = readAccessPermissions(existing.data);
|
|
1610
|
+
const nextPermissions = {
|
|
1611
|
+
...current,
|
|
1612
|
+
[permissionType]: {
|
|
1613
|
+
level: accessLevel,
|
|
1614
|
+
allowedDIDs: p.allowedDIDs,
|
|
1615
|
+
allowedCircles: p.allowedCircles,
|
|
1616
|
+
allowedGroups: p.allowedGroups
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
// Ride the signed update machinery so the override is a real
|
|
1620
|
+
// logged, synced mutation subject to normal write authz.
|
|
1621
|
+
return await ops.updateDocument({
|
|
1622
|
+
docID: p.documentID,
|
|
1623
|
+
patch: [
|
|
1624
|
+
{
|
|
1625
|
+
op: current == null ? 'add' : 'replace',
|
|
1626
|
+
path: '/accessPermissions',
|
|
1627
|
+
value: nextPermissions
|
|
1628
|
+
}
|
|
1629
|
+
]
|
|
1630
|
+
});
|
|
1631
|
+
},
|
|
1632
|
+
executeRemoveDocumentAccessOverride: async (documentID, permissionTypes)=>{
|
|
1633
|
+
const types = permissionTypes.map(assertPermissionType);
|
|
1634
|
+
const store = await getGraphStore(provider);
|
|
1635
|
+
const existing = await store.getDocument(DocumentID.fromString(documentID));
|
|
1636
|
+
if (existing == null) return;
|
|
1637
|
+
const current = readAccessPermissions(existing.data);
|
|
1638
|
+
if (current == null) return;
|
|
1639
|
+
const next = {
|
|
1640
|
+
...current
|
|
1641
|
+
};
|
|
1642
|
+
let changed = false;
|
|
1643
|
+
for (const type of types){
|
|
1644
|
+
if (type in next) {
|
|
1645
|
+
delete next[type];
|
|
1646
|
+
changed = true;
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
if (!changed) return;
|
|
1650
|
+
const patch = Object.keys(next).length > 0 ? [
|
|
1651
|
+
{
|
|
1652
|
+
op: 'replace',
|
|
1653
|
+
path: '/accessPermissions',
|
|
1654
|
+
value: next
|
|
1655
|
+
}
|
|
1656
|
+
] : [
|
|
1657
|
+
{
|
|
1658
|
+
op: 'remove',
|
|
1659
|
+
path: '/accessPermissions'
|
|
1660
|
+
}
|
|
1661
|
+
];
|
|
1662
|
+
await ops.updateDocument({
|
|
1663
|
+
docID: documentID,
|
|
1664
|
+
patch
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Execute a mutation operation whose fields are all declared
|
|
1671
|
+
* non-transactional. No outer write transaction is opened: the resolver runs
|
|
1672
|
+
* against the root provider (autocommit), and each signed document write
|
|
1673
|
+
* applies in its OWN transaction via `#applyVerifiedMutation` (the
|
|
1674
|
+
* per-mutation durability path), emitting its own events. Access-default
|
|
1675
|
+
* writes autocommit and emit immediately. There is no sync-wide rollback —
|
|
1676
|
+
* side-effecting sync-shaped ops are convergent and retry-safe, so per-step
|
|
1677
|
+
* atomicity (which each apply already owns) is the correct granularity.
|
|
1678
|
+
*
|
|
1679
|
+
* Signing and authorization are identical to the transactional path: writes
|
|
1680
|
+
* are still owner-signed by this engine's identity with held delegation
|
|
1681
|
+
* tokens auto-attached. Only the DB transaction boundary differs.
|
|
1682
|
+
*/ async #executeNonTransactionalMutation(params, signingIdentity) {
|
|
1683
|
+
// Floor the clock to the stored mutation maximum before any local mint
|
|
1684
|
+
// (autocommit document writes and access-default writes both mint below).
|
|
1685
|
+
// Idempotent/memoized, so a following per-apply floor is a no-op.
|
|
1686
|
+
await this.#ensureHLCFloored();
|
|
1687
|
+
// Read against the root provider — there is no ambient transaction here.
|
|
1688
|
+
const cap = await this.#collectWriteCapabilities({
|
|
1689
|
+
provider: this.#db,
|
|
1690
|
+
audience: signingIdentity.id,
|
|
1691
|
+
delegationTokens: params.delegationTokens
|
|
1692
|
+
});
|
|
1693
|
+
// Each signed write applies in its own transaction (no ambient provider)
|
|
1694
|
+
// and emits its own events — the same per-mutation durability the ingest
|
|
1695
|
+
// path uses. No shared `mutationResults` accumulator / post-commit emit.
|
|
1696
|
+
const signAndApply = async (mutation)=>{
|
|
1697
|
+
const signed = await signingIdentity.signToken(mutation);
|
|
1698
|
+
const jwt = stringifyToken(signed);
|
|
1699
|
+
const applied = await this.#applyVerifiedMutation({
|
|
1700
|
+
token: jwt
|
|
1701
|
+
});
|
|
1702
|
+
if (applied.document == null) {
|
|
1703
|
+
throw new Error('Unexpected dropped mutation in non-transactional write (no gate configured)');
|
|
1704
|
+
}
|
|
1705
|
+
return applied.document;
|
|
1706
|
+
};
|
|
1707
|
+
const ops = createMutationOperations({
|
|
1708
|
+
issuer: signingIdentity.id,
|
|
1709
|
+
hlc: this.#hlc,
|
|
1710
|
+
getRandomValues: this.#runtime.getRandomValues,
|
|
1711
|
+
owner: params.owner,
|
|
1712
|
+
cap,
|
|
1713
|
+
processSetMutation: (mutation)=>signAndApply(mutation),
|
|
1714
|
+
processChangeMutation: (mutation)=>signAndApply(mutation)
|
|
1715
|
+
});
|
|
1716
|
+
const contextExtensions = this.#buildGraphMutationContextExtensions({
|
|
1717
|
+
ops,
|
|
1718
|
+
provider: this.#db,
|
|
1719
|
+
signingIdentity,
|
|
1720
|
+
// No transaction to await — the store write already committed, so emit
|
|
1721
|
+
// the replication event immediately.
|
|
1722
|
+
onAccessDefault: (event)=>this.#eventBus.emit(event.name, event.data)
|
|
1723
|
+
});
|
|
1724
|
+
// No `stores` override: the plugin context factory and all reads use the
|
|
1725
|
+
// root provider (autocommit), so nothing joins a transaction that is not open.
|
|
1726
|
+
return await this.#execute({
|
|
1727
|
+
graphID: params.id,
|
|
1728
|
+
text: params.text,
|
|
1729
|
+
variables: params.variables ?? {},
|
|
1730
|
+
viewerDID: params.viewerDID ?? signingIdentity.id,
|
|
1731
|
+
contextExtensions
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1380
1734
|
async mutateGraph(params) {
|
|
1381
1735
|
if (!isSigningIdentity(this.#identity)) {
|
|
1382
1736
|
throw new Error('mutateGraph requires a SigningIdentity');
|
|
@@ -1386,9 +1740,30 @@ export class KubunEngine {
|
|
|
1386
1740
|
// Warm the schema/model cache before opening the write transaction. Building
|
|
1387
1741
|
// the schema reads the graph model from the main connection; doing that
|
|
1388
1742
|
// inside the transaction deadlocks on single-connection SQLite, where a read
|
|
1389
|
-
// cannot run while the connection's own transaction is open.
|
|
1743
|
+
// cannot run while the connection's own transaction is open. Warming also
|
|
1744
|
+
// populates `#nonTransactionalMutationFields` (from the graph's plugin
|
|
1745
|
+
// extensions), which the routing below reads.
|
|
1390
1746
|
await this.getGraphQLSchema(graphID);
|
|
1391
|
-
|
|
1747
|
+
// Route side-effecting mutation fields (peer connect/sync, dance
|
|
1748
|
+
// round-trips) OUTSIDE the write transaction. Such a resolver performs
|
|
1749
|
+
// network I/O and/or opens its own per-step transactions; holding the outer
|
|
1750
|
+
// write transaction across it would deadlock a nested apply on
|
|
1751
|
+
// single-connection SQLite. The opt-out affects ONLY the transaction —
|
|
1752
|
+
// signing and authorization are unchanged (see `#executeNonTransactionalMutation`).
|
|
1753
|
+
const rootFields = this.#getRootMutationFieldNames(parse(params.text));
|
|
1754
|
+
const nonTransactional = rootFields.filter((name)=>this.#nonTransactionalMutationFields.has(name));
|
|
1755
|
+
if (nonTransactional.length > 0) {
|
|
1756
|
+
if (nonTransactional.length !== rootFields.length) {
|
|
1757
|
+
throw new Error(`A mutation operation cannot mix transactional and non-transactional fields: ${rootFields.join(', ')}`);
|
|
1758
|
+
}
|
|
1759
|
+
return await this.#executeNonTransactionalMutation(params, signingIdentity);
|
|
1760
|
+
}
|
|
1761
|
+
// Access-default writes are local policy, not signed document mutations, so
|
|
1762
|
+
// they never ride `mutationResults`. Collect their replication events here
|
|
1763
|
+
// and fire them after the transaction commits (below), alongside the same
|
|
1764
|
+
// post-commit discipline the document events follow.
|
|
1765
|
+
const accessDefaultEvents = [];
|
|
1766
|
+
const result = await this.#signedWriteTransaction({
|
|
1392
1767
|
owner: params.owner,
|
|
1393
1768
|
delegationTokens: params.delegationTokens,
|
|
1394
1769
|
drive: async ({ ops, tx, writeErrors })=>{
|
|
@@ -1398,34 +1773,16 @@ export class KubunEngine {
|
|
|
1398
1773
|
variables: params.variables ?? {},
|
|
1399
1774
|
viewerDID: params.viewerDID ?? signingIdentity.id,
|
|
1400
1775
|
stores: tx,
|
|
1401
|
-
contextExtensions: {
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
executeSetMutation: async (p)=>{
|
|
1410
|
-
return await ops.setDocument({
|
|
1411
|
-
modelID: p.modelID,
|
|
1412
|
-
unique: p.unique,
|
|
1413
|
-
data: p.data,
|
|
1414
|
-
owner: p.owner
|
|
1415
|
-
});
|
|
1416
|
-
},
|
|
1417
|
-
executeUpdateMutation: async (p)=>{
|
|
1418
|
-
return await ops.updateDocument({
|
|
1419
|
-
docID: p.input.id,
|
|
1420
|
-
patch: convertPatchInput(p.input.patch)
|
|
1421
|
-
});
|
|
1422
|
-
},
|
|
1423
|
-
executeRemoveMutation: async (p)=>{
|
|
1424
|
-
await ops.removeDocument({
|
|
1425
|
-
docID: p.id
|
|
1426
|
-
});
|
|
1776
|
+
contextExtensions: this.#buildGraphMutationContextExtensions({
|
|
1777
|
+
ops,
|
|
1778
|
+
provider: tx,
|
|
1779
|
+
signingIdentity,
|
|
1780
|
+
// Transactional path: defer replication events so they fire only
|
|
1781
|
+
// after the write transaction commits (below).
|
|
1782
|
+
onAccessDefault: (event)=>{
|
|
1783
|
+
accessDefaultEvents.push(event);
|
|
1427
1784
|
}
|
|
1428
|
-
}
|
|
1785
|
+
})
|
|
1429
1786
|
});
|
|
1430
1787
|
// A plugin mutation resolver can mark its transaction fatal by throwing
|
|
1431
1788
|
// `TransactionFatalError`; graphql-js wraps that throw into a
|
|
@@ -1451,6 +1808,15 @@ export class KubunEngine {
|
|
|
1451
1808
|
return executed;
|
|
1452
1809
|
}
|
|
1453
1810
|
});
|
|
1811
|
+
// Emit access-default replication events after the transaction commits. A
|
|
1812
|
+
// rollback carries the sentinel result with `data === null`, so skip
|
|
1813
|
+
// emission there — the rule write did not persist.
|
|
1814
|
+
if (result.data != null) {
|
|
1815
|
+
for (const event of accessDefaultEvents){
|
|
1816
|
+
await this.#eventBus.emit(event.name, event.data);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
return result;
|
|
1454
1820
|
}
|
|
1455
1821
|
/**
|
|
1456
1822
|
* Apply a batch of document writes signed by this engine's identity, all
|
package/lib/errors.d.ts
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* (matching the RPC batch path) without throwing to the app.
|
|
11
11
|
*/
|
|
12
12
|
export declare class MutateGraphWriteRollback extends Error {
|
|
13
|
-
|
|
13
|
+
#private;
|
|
14
14
|
constructor(result: unknown);
|
|
15
|
+
get result(): unknown;
|
|
15
16
|
}
|
|
16
17
|
/**
|
|
17
18
|
* Marker a plugin mutation resolver throws to force its whole `mutateGraph`
|
|
@@ -23,9 +24,12 @@ export declare class MutateGraphWriteRollback extends Error {
|
|
|
23
24
|
* it and rolls back, matching the core-model write-failure path.
|
|
24
25
|
*/
|
|
25
26
|
export declare class TransactionFatalError extends Error {
|
|
27
|
+
#private;
|
|
26
28
|
constructor(message: string, options?: {
|
|
27
29
|
cause?: unknown;
|
|
30
|
+
extensions?: Record<string, unknown>;
|
|
28
31
|
});
|
|
32
|
+
get extensions(): Record<string, unknown> | undefined;
|
|
29
33
|
}
|
|
30
34
|
/** Whether `err` is a {@link TransactionFatalError} marker. */
|
|
31
35
|
export declare function isTransactionFatal(err: unknown): boolean;
|
package/lib/errors.js
CHANGED
|
@@ -9,10 +9,14 @@
|
|
|
9
9
|
* this sentinel out of the transaction callback makes the apply all-or-nothing
|
|
10
10
|
* (matching the RPC batch path) without throwing to the app.
|
|
11
11
|
*/ export class MutateGraphWriteRollback extends Error {
|
|
12
|
-
result;
|
|
12
|
+
#result;
|
|
13
13
|
constructor(result){
|
|
14
|
-
super('mutateGraph write resolver failed')
|
|
14
|
+
super('mutateGraph write resolver failed');
|
|
15
15
|
this.name = 'MutateGraphWriteRollback';
|
|
16
|
+
this.#result = result;
|
|
17
|
+
}
|
|
18
|
+
get result() {
|
|
19
|
+
return this.#result;
|
|
16
20
|
}
|
|
17
21
|
}
|
|
18
22
|
/**
|
|
@@ -24,9 +28,23 @@
|
|
|
24
28
|
* marker on any internal failure; `mutateGraph` scans the execution errors for
|
|
25
29
|
* it and rolls back, matching the core-model write-failure path.
|
|
26
30
|
*/ export class TransactionFatalError extends Error {
|
|
31
|
+
/**
|
|
32
|
+
* Extensions to carry onto the `GraphQLError` graphql-js wraps this throw in.
|
|
33
|
+
*
|
|
34
|
+
* `locatedError` copies `originalError.extensions` when it builds that
|
|
35
|
+
* wrapper, so this is what lets a TYPED refusal — an access code an app
|
|
36
|
+
* matches on — survive a rollback. Without it, every refusal raised inside a
|
|
37
|
+
* wrapped body reaches the client as a bare message, and the only way to keep
|
|
38
|
+
* a code was to run the check before the `try`, which a check that must read
|
|
39
|
+
* the state it is refusing cannot do.
|
|
40
|
+
*/ #extensions;
|
|
27
41
|
constructor(message, options){
|
|
28
42
|
super(message, options);
|
|
29
43
|
this.name = 'TransactionFatalError';
|
|
44
|
+
this.#extensions = options?.extensions;
|
|
45
|
+
}
|
|
46
|
+
get extensions() {
|
|
47
|
+
return this.#extensions;
|
|
30
48
|
}
|
|
31
49
|
}
|
|
32
50
|
/** Whether `err` is a {@link TransactionFatalError} marker. */ export function isTransactionFatal(err) {
|
package/lib/plugin.d.ts
CHANGED
|
@@ -14,6 +14,25 @@ import type { PolicyGateMap } from './policies.js';
|
|
|
14
14
|
export type SchemaExtension = {
|
|
15
15
|
sdl: string;
|
|
16
16
|
resolvers: ExtensionResolvers;
|
|
17
|
+
/**
|
|
18
|
+
* Names of `Mutation` fields whose resolvers must run OUTSIDE `mutateGraph`'s
|
|
19
|
+
* write transaction. Side-effecting operations (peer connect/sync, dance
|
|
20
|
+
* round-trips) perform network I/O and/or open their own per-step
|
|
21
|
+
* transactions; wrapping them in the outer write transaction would hold the
|
|
22
|
+
* DB connection across a network round-trip and, on single-connection
|
|
23
|
+
* SQLite, deadlock a nested apply against the ambient transaction.
|
|
24
|
+
*
|
|
25
|
+
* The opt-out affects ONLY the DB transaction. Such resolvers still run
|
|
26
|
+
* through `mutateGraph`'s owner-signed authorization path and still receive
|
|
27
|
+
* the signed mutation operations (`executeCreateMutation`, etc.), which
|
|
28
|
+
* autocommit each write instead of sharing one ambient transaction. Store
|
|
29
|
+
* reads and writes route through the root provider, so nothing joins a
|
|
30
|
+
* transaction that is not open.
|
|
31
|
+
*
|
|
32
|
+
* A single mutation operation must not mix transactional and
|
|
33
|
+
* non-transactional root fields — the engine rejects such an operation.
|
|
34
|
+
*/
|
|
35
|
+
nonTransactionalMutationFields?: Array<string>;
|
|
17
36
|
};
|
|
18
37
|
/**
|
|
19
38
|
* Low-level graph operations available only to plugins.
|
package/package.json
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/engine",
|
|
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
|
-
"@
|
|
19
|
-
"@kokuin/
|
|
20
|
-
"@sozai/event": "^0.1.0",
|
|
21
|
-
"@sozai/runtime": "^0.1.0",
|
|
22
|
-
"@sozai/schema": "^0.1.0",
|
|
23
|
-
"@kokuin/token": "^0.1.1",
|
|
18
|
+
"@kokuin/capability": "^0.2.1",
|
|
19
|
+
"@kokuin/token": "^0.3.0",
|
|
24
20
|
"@noble/ciphers": "^2.2.0",
|
|
25
21
|
"@noble/hashes": "^2.2.0",
|
|
26
|
-
"@sozai/
|
|
22
|
+
"@sozai/async": "^0.2.1",
|
|
23
|
+
"@sozai/codec": "^0.4.0",
|
|
24
|
+
"@sozai/event": "^0.1.3",
|
|
25
|
+
"@sozai/runtime": "^0.1.0",
|
|
26
|
+
"@sozai/schema": "^0.1.1",
|
|
27
27
|
"graphql": "^16.14.2",
|
|
28
|
-
"@kubun/db": "^0.
|
|
29
|
-
"@kubun/
|
|
30
|
-
"@kubun/db-adapter": "^0.
|
|
31
|
-
"@kubun/
|
|
32
|
-
"@kubun/
|
|
33
|
-
"@kubun/
|
|
34
|
-
"@kubun/
|
|
35
|
-
"@kubun/
|
|
36
|
-
"@kubun/store-graph": "^0.
|
|
37
|
-
"@kubun/store-p2p": "^0.
|
|
28
|
+
"@kubun/db": "^0.12.0",
|
|
29
|
+
"@kubun/hlc": "^0.12.0",
|
|
30
|
+
"@kubun/db-adapter": "^0.12.0",
|
|
31
|
+
"@kubun/logger": "^0.12.0",
|
|
32
|
+
"@kubun/graphql": "^0.12.0",
|
|
33
|
+
"@kubun/store-delegation": "^0.12.0",
|
|
34
|
+
"@kubun/mutation": "^0.12.0",
|
|
35
|
+
"@kubun/protocol": "^0.12.0",
|
|
36
|
+
"@kubun/store-graph": "^0.12.0",
|
|
37
|
+
"@kubun/store-p2p": "^0.12.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@testcontainers/postgresql": "^12.0
|
|
41
|
-
"@kubun/
|
|
42
|
-
"@kubun/
|
|
43
|
-
"@kubun/test-utils": "^0.
|
|
40
|
+
"@testcontainers/postgresql": "^12.1.0",
|
|
41
|
+
"@kubun/id": "^0.12.0",
|
|
42
|
+
"@kubun/db-postgres": "^0.12.0",
|
|
43
|
+
"@kubun/test-utils": "^0.12.0"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
46
47
|
"build:clean": "del lib",
|
|
47
48
|
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
48
49
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
49
50
|
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
50
|
-
"
|
|
51
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
51
52
|
"test:types": "tsc --noEmit -p tsconfig.test.json",
|
|
52
|
-
"test:unit": "vitest run"
|
|
53
|
-
"test": "pnpm run test:types && pnpm run test:unit"
|
|
53
|
+
"test:unit": "vitest run"
|
|
54
54
|
}
|
|
55
55
|
}
|