@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.
@@ -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 { type VerifyTokenHook } from '@kokuin/capability';
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 { createRevocationBackend, delegationStoreDefinition, getDelegationStore } from '@kubun/store-delegation';
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 = createRevocationChecker(createRevocationBackend(delegationStore));
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 = createRevocationChecker(createRevocationBackend(delegationStore));
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 = createRevocationChecker(createRevocationBackend(delegationStore));
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
- // Auto-attach delegation tokens this device holds so a delegate can write
1326
- // to documents it does not own without the app passing tokens manually.
1327
- // Delegation storage is core-registered and always present. Read through
1328
- // the transaction provider `tx`, not the main connection: querying the
1329
- // main connection while this write transaction is open deadlocks on
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
- atTime
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
- return await this.#signedWriteTransaction({
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
- executeCreateMutation: async (p)=>{
1403
- return await ops.createDocument({
1404
- modelID: p.modelID,
1405
- data: p.data,
1406
- owner: p.owner
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
- readonly result: unknown;
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'), this.result = result;
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.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/async": "^0.1.0",
19
- "@kokuin/capability": "^0.1.0",
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/codec": "^0.1.0",
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.11.0",
29
- "@kubun/graphql": "^0.11.0",
30
- "@kubun/db-adapter": "^0.11.0",
31
- "@kubun/protocol": "^0.11.0",
32
- "@kubun/logger": "^0.11.0",
33
- "@kubun/hlc": "^0.11.0",
34
- "@kubun/store-delegation": "^0.11.0",
35
- "@kubun/mutation": "^0.11.0",
36
- "@kubun/store-graph": "^0.11.0",
37
- "@kubun/store-p2p": "^0.11.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.4",
41
- "@kubun/db-postgres": "^0.11.0",
42
- "@kubun/id": "^0.11.0",
43
- "@kubun/test-utils": "^0.11.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
- "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
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
  }