@kubun/engine 0.10.3 → 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/access-control.d.ts +1 -1
- package/lib/access-control.js +250 -1
- package/lib/catalog-match.js +34 -1
- package/lib/cipher.d.ts +6 -0
- package/lib/cipher.js +40 -0
- package/lib/engine-events.d.ts +24 -0
- package/lib/engine-events.js +6 -1
- package/lib/engine.d.ts +88 -11
- package/lib/engine.js +1877 -1
- package/lib/errors.d.ts +35 -0
- package/lib/errors.js +52 -0
- package/lib/events.d.ts +3 -3
- package/lib/events.js +85 -1
- package/lib/executor.d.ts +7 -0
- package/lib/executor.js +1 -1
- package/lib/index.d.ts +4 -1
- package/lib/index.js +10 -1
- package/lib/membership-check.js +99 -1
- package/lib/mutation-hash.js +6 -1
- package/lib/plugin.d.ts +31 -3
- package/lib/plugin.js +7 -1
- package/lib/policies.js +55 -1
- package/lib/registry.js +25 -1
- package/package.json +31 -28
package/lib/errors.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown at the end of a `mutateGraph` transaction callback to force the whole
|
|
3
|
+
* transaction to roll back when any write resolver failed, while carrying the
|
|
4
|
+
* graphql execution result so the caller still receives the `errors` payload.
|
|
5
|
+
*
|
|
6
|
+
* graphql-js swallows a resolver throw into `result.errors` and resolves the
|
|
7
|
+
* execution promise, so a multi-field mutation whose first field applied and
|
|
8
|
+
* whose second field threw would otherwise commit a partial write. Throwing
|
|
9
|
+
* this sentinel out of the transaction callback makes the apply all-or-nothing
|
|
10
|
+
* (matching the RPC batch path) without throwing to the app.
|
|
11
|
+
*/
|
|
12
|
+
export declare class MutateGraphWriteRollback extends Error {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(result: unknown);
|
|
15
|
+
get result(): unknown;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Marker a plugin mutation resolver throws to force its whole `mutateGraph`
|
|
19
|
+
* transaction to roll back. graphql-js swallows a resolver throw into
|
|
20
|
+
* `result.errors`, so a plugin op that performs several writes (e.g. an MLS
|
|
21
|
+
* ratchet advance followed by a roster write) could otherwise commit a partial
|
|
22
|
+
* result when a later step fails. A resolver that wraps such an op rethrows this
|
|
23
|
+
* marker on any internal failure; `mutateGraph` scans the execution errors for
|
|
24
|
+
* it and rolls back, matching the core-model write-failure path.
|
|
25
|
+
*/
|
|
26
|
+
export declare class TransactionFatalError extends Error {
|
|
27
|
+
#private;
|
|
28
|
+
constructor(message: string, options?: {
|
|
29
|
+
cause?: unknown;
|
|
30
|
+
extensions?: Record<string, unknown>;
|
|
31
|
+
});
|
|
32
|
+
get extensions(): Record<string, unknown> | undefined;
|
|
33
|
+
}
|
|
34
|
+
/** Whether `err` is a {@link TransactionFatalError} marker. */
|
|
35
|
+
export declare function isTransactionFatal(err: unknown): boolean;
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown at the end of a `mutateGraph` transaction callback to force the whole
|
|
3
|
+
* transaction to roll back when any write resolver failed, while carrying the
|
|
4
|
+
* graphql execution result so the caller still receives the `errors` payload.
|
|
5
|
+
*
|
|
6
|
+
* graphql-js swallows a resolver throw into `result.errors` and resolves the
|
|
7
|
+
* execution promise, so a multi-field mutation whose first field applied and
|
|
8
|
+
* whose second field threw would otherwise commit a partial write. Throwing
|
|
9
|
+
* this sentinel out of the transaction callback makes the apply all-or-nothing
|
|
10
|
+
* (matching the RPC batch path) without throwing to the app.
|
|
11
|
+
*/ export class MutateGraphWriteRollback extends Error {
|
|
12
|
+
#result;
|
|
13
|
+
constructor(result){
|
|
14
|
+
super('mutateGraph write resolver failed');
|
|
15
|
+
this.name = 'MutateGraphWriteRollback';
|
|
16
|
+
this.#result = result;
|
|
17
|
+
}
|
|
18
|
+
get result() {
|
|
19
|
+
return this.#result;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Marker a plugin mutation resolver throws to force its whole `mutateGraph`
|
|
24
|
+
* transaction to roll back. graphql-js swallows a resolver throw into
|
|
25
|
+
* `result.errors`, so a plugin op that performs several writes (e.g. an MLS
|
|
26
|
+
* ratchet advance followed by a roster write) could otherwise commit a partial
|
|
27
|
+
* result when a later step fails. A resolver that wraps such an op rethrows this
|
|
28
|
+
* marker on any internal failure; `mutateGraph` scans the execution errors for
|
|
29
|
+
* it and rolls back, matching the core-model write-failure path.
|
|
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;
|
|
41
|
+
constructor(message, options){
|
|
42
|
+
super(message, options);
|
|
43
|
+
this.name = 'TransactionFatalError';
|
|
44
|
+
this.#extensions = options?.extensions;
|
|
45
|
+
}
|
|
46
|
+
get extensions() {
|
|
47
|
+
return this.#extensions;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Whether `err` is a {@link TransactionFatalError} marker. */ export function isTransactionFatal(err) {
|
|
51
|
+
return err instanceof TransactionFatalError;
|
|
52
|
+
}
|
package/lib/events.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { type UnsubscribeFunction } from '@enkaku/event';
|
|
2
1
|
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import { type UnsubscribeFunction } from '@sozai/event';
|
|
3
3
|
/**
|
|
4
4
|
* Map of event names to their data types.
|
|
5
5
|
* Uses `Record<string, unknown>` so any namespaced string key is valid.
|
|
6
6
|
*/
|
|
7
7
|
type EventMap = Record<string, unknown>;
|
|
8
8
|
/**
|
|
9
|
-
* Transaction-aware event bus built on top of `@
|
|
9
|
+
* Transaction-aware event bus built on top of `@sozai/event`.
|
|
10
10
|
*
|
|
11
11
|
* Events emitted outside a transaction fire immediately.
|
|
12
12
|
* Events emitted inside a transaction are buffered until commit,
|
|
@@ -42,7 +42,7 @@ export declare class EngineEventBus<TEvents extends EventMap = EventMap> {
|
|
|
42
42
|
* in the order they were emitted.
|
|
43
43
|
*
|
|
44
44
|
* Listener errors do not prevent remaining buffered events from firing.
|
|
45
|
-
* Since `@
|
|
45
|
+
* Since `@sozai/event` uses `Promise.allSettled` internally, all listeners
|
|
46
46
|
* for a given event run even if some throw. Errors from individual event
|
|
47
47
|
* emissions are caught so the full buffer is always drained.
|
|
48
48
|
*/
|
package/lib/events.js
CHANGED
|
@@ -1 +1,85 @@
|
|
|
1
|
-
import{EventEmitter
|
|
1
|
+
import { EventEmitter } from '@sozai/event';
|
|
2
|
+
/**
|
|
3
|
+
* Transaction-aware event bus built on top of `@sozai/event`.
|
|
4
|
+
*
|
|
5
|
+
* Events emitted outside a transaction fire immediately.
|
|
6
|
+
* Events emitted inside a transaction are buffered until commit,
|
|
7
|
+
* and discarded on rollback.
|
|
8
|
+
*/ export class EngineEventBus {
|
|
9
|
+
#emitter;
|
|
10
|
+
#logger;
|
|
11
|
+
#transactions;
|
|
12
|
+
constructor(options){
|
|
13
|
+
this.#emitter = new EventEmitter();
|
|
14
|
+
this.#logger = options?.logger;
|
|
15
|
+
this.#transactions = new Map();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Subscribe to a namespaced event.
|
|
19
|
+
* Returns an unsubscribe function.
|
|
20
|
+
*/ on(name, listener) {
|
|
21
|
+
return this.#emitter.on(name, listener);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Emit an event immediately (not inside any transaction).
|
|
25
|
+
*/ async emit(name, data) {
|
|
26
|
+
await this.#emitter.emit(name, data);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Begin a new transaction context for event buffering.
|
|
30
|
+
* Events emitted via `emitBuffered` with this transaction ID
|
|
31
|
+
* will be held until `commit` or `rollback` is called.
|
|
32
|
+
*/ beginTransaction(transactionID) {
|
|
33
|
+
if (this.#transactions.has(transactionID)) {
|
|
34
|
+
throw new Error(`Transaction already exists: ${transactionID}`);
|
|
35
|
+
}
|
|
36
|
+
this.#transactions.set(transactionID, []);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Emit an event inside a transaction. The event is buffered
|
|
40
|
+
* and will only be delivered to listeners upon commit.
|
|
41
|
+
*/ emitBuffered(transactionID, name, data) {
|
|
42
|
+
const buffer = this.#transactions.get(transactionID);
|
|
43
|
+
if (buffer == null) {
|
|
44
|
+
throw new Error(`Transaction not found: ${transactionID}`);
|
|
45
|
+
}
|
|
46
|
+
buffer.push({
|
|
47
|
+
name,
|
|
48
|
+
data
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Commit a transaction: release all buffered events to listeners
|
|
53
|
+
* in the order they were emitted.
|
|
54
|
+
*
|
|
55
|
+
* Listener errors do not prevent remaining buffered events from firing.
|
|
56
|
+
* Since `@sozai/event` uses `Promise.allSettled` internally, all listeners
|
|
57
|
+
* for a given event run even if some throw. Errors from individual event
|
|
58
|
+
* emissions are caught so the full buffer is always drained.
|
|
59
|
+
*/ async commit(transactionID) {
|
|
60
|
+
const buffer = this.#transactions.get(transactionID);
|
|
61
|
+
if (buffer == null) {
|
|
62
|
+
throw new Error(`Transaction not found: ${transactionID}`);
|
|
63
|
+
}
|
|
64
|
+
this.#transactions.delete(transactionID);
|
|
65
|
+
for (const event of buffer){
|
|
66
|
+
try {
|
|
67
|
+
await this.#emitter.emit(event.name, event.data);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// Listener errors do not prevent remaining buffered events from firing.
|
|
70
|
+
this.#logger?.warn('event listener error during commit for {event}: {error}', {
|
|
71
|
+
event: event.name,
|
|
72
|
+
error: String(err)
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Rollback a transaction: discard all buffered events.
|
|
79
|
+
*/ rollback(transactionID) {
|
|
80
|
+
if (!this.#transactions.has(transactionID)) {
|
|
81
|
+
throw new Error(`Transaction not found: ${transactionID}`);
|
|
82
|
+
}
|
|
83
|
+
this.#transactions.delete(transactionID);
|
|
84
|
+
}
|
|
85
|
+
}
|
package/lib/executor.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ import type { GetAPI } from './registry.js';
|
|
|
8
8
|
*/
|
|
9
9
|
export type EngineGraphParams = ExecuteGraphParams & {
|
|
10
10
|
viewerDID?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Document owner for a delegated write: the server signs the mutation with its
|
|
13
|
+
* own identity but the resulting document is owned by `owner` (the viewer),
|
|
14
|
+
* authorized by a held viewer→server delegation capability. Omitted means the
|
|
15
|
+
* owner is the signer. Used by `mutateGraph` only.
|
|
16
|
+
*/
|
|
17
|
+
owner?: string;
|
|
11
18
|
/**
|
|
12
19
|
* Delegation tokens authorizing writes to documents the caller does not own.
|
|
13
20
|
* Carried on each outgoing mutation's `cap` field (part of the signed payload)
|
package/lib/executor.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{};
|
|
1
|
+
export { };
|
package/lib/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export type { AccessChecker, AccessControlDB, AccessLevel, AccessPermissions, AccessRule, DefaultAccessLevel, } from './access-control.js';
|
|
2
2
|
export { createAccessChecker, parseDocumentAccessPermissions, resolveAccessRule, validateDIDs, validateID, validateIDs, } from './access-control.js';
|
|
3
3
|
export { catalogMatchesDoc } from './catalog-match.js';
|
|
4
|
-
export type {
|
|
4
|
+
export type { Cipher } from './cipher.js';
|
|
5
|
+
export { createDefaultCipher, deriveAtRestKey } from './cipher.js';
|
|
6
|
+
export type { AccessGate, AccessGateFactory, AccessGateStores, ApplyVerifiedMutationParams, ApplyVerifiedMutationResult, ApplyVerifiedMutationsParams, ApplyVerifiedMutationsResult, ContextFactory, DocumentWrite, EngineParams, ExecuteParams, ExecutionContext, MutateDocumentsParams, } from './engine.js';
|
|
5
7
|
export { KubunEngine } from './engine.js';
|
|
6
8
|
export type { EngineEvents } from './engine-events.js';
|
|
9
|
+
export { isTransactionFatal, TransactionFatalError } from './errors.js';
|
|
7
10
|
export { EngineEventBus } from './events.js';
|
|
8
11
|
export type { Engine, EngineGraphParams, GraphQLOperationParams, GraphQLSource, GraphQLSourceParams, GraphQLSourceProvider, GraphsProvider, } from './executor.js';
|
|
9
12
|
export type { CheckMembershipParams, CheckMembershipResult } from './membership-check.js';
|
package/lib/index.js
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
-
export{createAccessChecker,parseDocumentAccessPermissions,resolveAccessRule,validateDIDs,validateID,validateIDs}from
|
|
1
|
+
export { createAccessChecker, parseDocumentAccessPermissions, resolveAccessRule, validateDIDs, validateID, validateIDs } from './access-control.js';
|
|
2
|
+
export { catalogMatchesDoc } from './catalog-match.js';
|
|
3
|
+
export { createDefaultCipher, deriveAtRestKey } from './cipher.js';
|
|
4
|
+
export { KubunEngine } from './engine.js';
|
|
5
|
+
export { isTransactionFatal, TransactionFatalError } from './errors.js';
|
|
6
|
+
export { EngineEventBus } from './events.js';
|
|
7
|
+
export { checkMembership } from './membership-check.js';
|
|
8
|
+
export { computeMutationHash } from './mutation-hash.js';
|
|
9
|
+
export { runPolicies } from './policies.js';
|
|
10
|
+
export { createRegistry } from './registry.js';
|
package/lib/membership-check.js
CHANGED
|
@@ -1 +1,99 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { catalogMatchesDoc } from './catalog-match.js';
|
|
3
|
+
/**
|
|
4
|
+
* Decide whether a mutation's issuer is still permitted to write to the doc's
|
|
5
|
+
* MLS group(s) at the mutation's HLC.
|
|
6
|
+
*
|
|
7
|
+
* A group rejects the issuer iff a membership row exists with a removal HLC
|
|
8
|
+
* that is non-null AND causally at or before the mutation's HLC: the issuer was
|
|
9
|
+
* removed before (or exactly at) the moment the mutation claims. A missing row,
|
|
10
|
+
* an active member (`removed_at_hlc === null`), or a removal recorded strictly
|
|
11
|
+
* after the mutation (a pre-removal mutation) all accept.
|
|
12
|
+
*
|
|
13
|
+
* The serialized HLC form is lexicographically ordered, so a string `<=`
|
|
14
|
+
* compare matches HLC ordering — the same convention the soft-delete LWW uses.
|
|
15
|
+
*
|
|
16
|
+
* On the receive path (`arrivalGroupID` set) only that one group is checked.
|
|
17
|
+
* On the RPC path the candidate groups are the groups behind every circle the
|
|
18
|
+
* applying peer belongs to whose catalog covers the document; an empty walk
|
|
19
|
+
* means the doc lies outside this peer's enforceable perimeter and is accepted.
|
|
20
|
+
*/ export async function checkMembership(params) {
|
|
21
|
+
const { p2pStore, graphStore, selfDID, mutationHLC, doc, arrivalGroupID } = params;
|
|
22
|
+
// did:peer:4 issuers can arrive in long form; the roster stores the short
|
|
23
|
+
// form, so normalize before any roster comparison.
|
|
24
|
+
const iss = normalizeDID(params.iss);
|
|
25
|
+
// Returns the removal HLC that denies the issuer in this group, or undefined
|
|
26
|
+
// when the group does not reject (no row, active member, or a removal recorded
|
|
27
|
+
// strictly after the mutation).
|
|
28
|
+
const rejectionHLC = async (groupID)=>{
|
|
29
|
+
const removal = await p2pStore.getMemberRemoval(groupID, iss);
|
|
30
|
+
if (removal == null || removal.removed_at_hlc == null) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return removal.removed_at_hlc <= mutationHLC ? removal.removed_at_hlc : undefined;
|
|
34
|
+
};
|
|
35
|
+
// Receive branch: the envelope already pins the group — check only it.
|
|
36
|
+
if (arrivalGroupID != null) {
|
|
37
|
+
const removedAtHLC = await rejectionHLC(arrivalGroupID);
|
|
38
|
+
if (removedAtHLC != null) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
removedGroupID: arrivalGroupID,
|
|
42
|
+
removedAtHLC
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
ok: true
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// RPC branch: resolve candidate groups via the circle walk.
|
|
50
|
+
const circles = await p2pStore.getCirclesForMember(selfDID);
|
|
51
|
+
if (circles.length === 0) {
|
|
52
|
+
return {
|
|
53
|
+
ok: true
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Batch-fetch every catalog referenced across the peer's circles in one query.
|
|
57
|
+
const catalogIDSet = new Set();
|
|
58
|
+
for (const circle of circles){
|
|
59
|
+
for (const catalogID of circle.catalog_ids ?? []){
|
|
60
|
+
catalogIDSet.add(catalogID);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
let catalogsByID = new Map();
|
|
64
|
+
if (catalogIDSet.size > 0) {
|
|
65
|
+
catalogsByID = await graphStore.getCatalogs(Array.from(catalogIDSet));
|
|
66
|
+
}
|
|
67
|
+
// A circle's group is a candidate iff at least one of its catalogs covers
|
|
68
|
+
// the document.
|
|
69
|
+
const candidateGroupIDs = new Set();
|
|
70
|
+
for (const circle of circles){
|
|
71
|
+
for (const catalogID of circle.catalog_ids ?? []){
|
|
72
|
+
const catalog = catalogsByID.get(catalogID);
|
|
73
|
+
if (catalog == null) continue;
|
|
74
|
+
if (await catalogMatchesDoc(catalog.filter_criteria, doc, p2pStore)) {
|
|
75
|
+
candidateGroupIDs.add(circle.group_id);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Empty walk: doc is outside this peer's enforceable perimeter — accept.
|
|
81
|
+
if (candidateGroupIDs.size === 0) {
|
|
82
|
+
return {
|
|
83
|
+
ok: true
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
for (const groupID of candidateGroupIDs){
|
|
87
|
+
const removedAtHLC = await rejectionHLC(groupID);
|
|
88
|
+
if (removedAtHLC != null) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
removedGroupID: groupID,
|
|
92
|
+
removedAtHLC
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
ok: true
|
|
98
|
+
};
|
|
99
|
+
}
|
package/lib/mutation-hash.js
CHANGED
|
@@ -1 +1,6 @@
|
|
|
1
|
-
import{blake3
|
|
1
|
+
import { blake3 } from '@noble/hashes/blake3.js';
|
|
2
|
+
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
export function computeMutationHash(jwt) {
|
|
5
|
+
return bytesToHex(blake3(encoder.encode(jwt)));
|
|
6
|
+
}
|
package/lib/plugin.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { Identity } from '@enkaku/token';
|
|
1
|
+
import type { Identity } from '@kokuin/token';
|
|
3
2
|
import type { KubunDB } from '@kubun/db';
|
|
4
3
|
import type { ExtensionResolvers } from '@kubun/graphql';
|
|
5
4
|
import type { HLC } from '@kubun/hlc';
|
|
6
5
|
import type { Logger } from '@kubun/logger';
|
|
6
|
+
import type { Runtime } from '@sozai/runtime';
|
|
7
7
|
import type { ExecutionResult } from 'graphql';
|
|
8
|
-
import type {
|
|
8
|
+
import type { Cipher } from './cipher.js';
|
|
9
|
+
import type { ApplyVerifiedMutationParams, ApplyVerifiedMutationResult, ApplyVerifiedMutationsParams, ApplyVerifiedMutationsResult, ContextFactory, ExecuteParams, MutateDocumentsParams } from './engine.js';
|
|
9
10
|
import type { EngineEvents } from './engine-events.js';
|
|
10
11
|
import type { EngineEventBus } from './events.js';
|
|
11
12
|
import type { Engine } from './executor.js';
|
|
@@ -13,6 +14,25 @@ import type { PolicyGateMap } from './policies.js';
|
|
|
13
14
|
export type SchemaExtension = {
|
|
14
15
|
sdl: string;
|
|
15
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>;
|
|
16
36
|
};
|
|
17
37
|
/**
|
|
18
38
|
* Low-level graph operations available only to plugins.
|
|
@@ -24,6 +44,12 @@ export type GraphInternals = {
|
|
|
24
44
|
subscribe<Data extends Record<string, unknown> = Record<string, unknown>>(params: ExecuteParams): Promise<AsyncGenerator<ExecutionResult<Data>> | ExecutionResult<Data>>;
|
|
25
45
|
applyVerifiedMutation(params: ApplyVerifiedMutationParams): Promise<ApplyVerifiedMutationResult>;
|
|
26
46
|
applyVerifiedMutations(params: ApplyVerifiedMutationsParams): Promise<ApplyVerifiedMutationsResult>;
|
|
47
|
+
/**
|
|
48
|
+
* Apply a batch of document writes signed by the engine identity — optionally
|
|
49
|
+
* on behalf of `owner` — within one signed write transaction. No GraphQL text
|
|
50
|
+
* is parsed or executed.
|
|
51
|
+
*/
|
|
52
|
+
mutateDocuments(params: MutateDocumentsParams): Promise<Array<ApplyVerifiedMutationResult>>;
|
|
27
53
|
};
|
|
28
54
|
/**
|
|
29
55
|
* Parameters provided by the engine to plugin factories.
|
|
@@ -39,6 +65,8 @@ export type PluginFactoryParams = {
|
|
|
39
65
|
eventBus: EngineEventBus<EngineEvents>;
|
|
40
66
|
hlc: HLC;
|
|
41
67
|
getLogger: (name: string) => Logger;
|
|
68
|
+
/** Shared at-rest cipher; undefined when no key is available. */
|
|
69
|
+
cipher: Cipher | undefined;
|
|
42
70
|
};
|
|
43
71
|
/**
|
|
44
72
|
* Plugin descriptor returned by a plugin factory.
|
package/lib/plugin.js
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Plugin descriptor returned by a plugin factory.
|
|
3
|
+
*
|
|
4
|
+
* Plugins expose APIs via the `api` field. Other plugins access them
|
|
5
|
+
* via `engine.getAPI(name)`. Lifecycle coordination uses lazy promises
|
|
6
|
+
* on plugin APIs (e.g. `httpAPI.listening`) rather than a central registry.
|
|
7
|
+
*/ export { };
|
package/lib/policies.js
CHANGED
|
@@ -1 +1,55 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Policy gate module for the Kubun engine.
|
|
3
|
+
*
|
|
4
|
+
* Policies gate engine operations with sync-before-async ordering.
|
|
5
|
+
* Each gate has optional sync and async check arrays. Sync checks run first
|
|
6
|
+
* (fast, in-memory). If any sync check fails, async checks are skipped entirely.
|
|
7
|
+
*
|
|
8
|
+
* Result types follow the Standard Schema output specification:
|
|
9
|
+
* - Success: `{ value }` — the context passes through
|
|
10
|
+
* - Failure: `{ issues }` — an array of issues with at minimum a `message` field
|
|
11
|
+
*/ // ---- Standard Schema-compatible result types (https://standardschema.dev) ----
|
|
12
|
+
/**
|
|
13
|
+
* A path segment in a policy issue, matching StandardSchemaV1.PathSegment.
|
|
14
|
+
*/ // ---- Execution ----
|
|
15
|
+
function isFailure(result) {
|
|
16
|
+
return 'issues' in result && result.issues != null && result.issues.length > 0;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Run all policy checks for a given gate type.
|
|
20
|
+
*
|
|
21
|
+
* Execution order:
|
|
22
|
+
* 1. Run ALL sync checks — fail fast if any return issues
|
|
23
|
+
* 2. Run ALL async checks — fail fast if any return issues
|
|
24
|
+
* 3. Return success with the original context
|
|
25
|
+
*
|
|
26
|
+
* If the gate type is not present in the map, the operation is allowed by default.
|
|
27
|
+
*/ export async function runPolicies(gates, gateType, context) {
|
|
28
|
+
const gate = gates[gateType];
|
|
29
|
+
// No gate defined for this type — allow by default
|
|
30
|
+
if (gate == null) {
|
|
31
|
+
return {
|
|
32
|
+
value: context
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// Phase 1: Run sync checks (fast, in-memory)
|
|
36
|
+
const syncChecks = gate.sync ?? [];
|
|
37
|
+
for (const check of syncChecks){
|
|
38
|
+
const result = check(context);
|
|
39
|
+
if (isFailure(result)) {
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Phase 2: Run async checks (potentially slow — DB, network, etc.)
|
|
44
|
+
const asyncChecks = gate.async ?? [];
|
|
45
|
+
for (const check of asyncChecks){
|
|
46
|
+
const result = await check(context);
|
|
47
|
+
if (isFailure(result)) {
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// All checks passed
|
|
52
|
+
return {
|
|
53
|
+
value: context
|
|
54
|
+
};
|
|
55
|
+
}
|
package/lib/registry.js
CHANGED
|
@@ -1 +1,25 @@
|
|
|
1
|
-
import{defer
|
|
1
|
+
import { defer } from '@sozai/async';
|
|
2
|
+
export function createRegistry() {
|
|
3
|
+
const gate = defer();
|
|
4
|
+
const pluginAPIs = {};
|
|
5
|
+
function registerPlugin(name, api) {
|
|
6
|
+
pluginAPIs[name] = api;
|
|
7
|
+
}
|
|
8
|
+
function closeGate() {
|
|
9
|
+
gate.resolve();
|
|
10
|
+
}
|
|
11
|
+
function getAPI(name) {
|
|
12
|
+
return gate.promise.then(()=>{
|
|
13
|
+
const api = pluginAPIs[name];
|
|
14
|
+
if (api == null) {
|
|
15
|
+
throw new Error(`API not registered: ${name}`);
|
|
16
|
+
}
|
|
17
|
+
return api;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
registerPlugin,
|
|
22
|
+
closeGate,
|
|
23
|
+
getAPI
|
|
24
|
+
};
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,52 +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
|
-
"@
|
|
20
|
-
"@
|
|
21
|
-
"@enkaku/runtime": "^0.16.0",
|
|
22
|
-
"@enkaku/schema": "^0.16.0",
|
|
23
|
-
"@enkaku/token": "^0.16.0",
|
|
18
|
+
"@kokuin/capability": "^0.2.1",
|
|
19
|
+
"@kokuin/token": "^0.3.0",
|
|
20
|
+
"@noble/ciphers": "^2.2.0",
|
|
24
21
|
"@noble/hashes": "^2.2.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",
|
|
25
27
|
"graphql": "^16.14.2",
|
|
26
|
-
"@kubun/
|
|
27
|
-
"@kubun/
|
|
28
|
-
"@kubun/
|
|
29
|
-
"@kubun/
|
|
30
|
-
"@kubun/
|
|
31
|
-
"@kubun/
|
|
32
|
-
"@kubun/
|
|
33
|
-
"@kubun/
|
|
34
|
-
"@kubun/
|
|
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"
|
|
35
38
|
},
|
|
36
39
|
"devDependencies": {
|
|
37
|
-
"@testcontainers/postgresql": "^12.0
|
|
38
|
-
"@kubun/
|
|
39
|
-
"@kubun/
|
|
40
|
-
"@kubun/
|
|
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"
|
|
41
44
|
},
|
|
42
45
|
"scripts": {
|
|
46
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
43
47
|
"build:clean": "del lib",
|
|
44
|
-
"build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
|
|
48
|
+
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
45
49
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
46
50
|
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
47
|
-
"
|
|
51
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
48
52
|
"test:types": "tsc --noEmit -p tsconfig.test.json",
|
|
49
|
-
"test:unit": "vitest run"
|
|
50
|
-
"test": "pnpm run test:types && pnpm run test:unit"
|
|
53
|
+
"test:unit": "vitest run"
|
|
51
54
|
}
|
|
52
55
|
}
|