@kubun/store-controller 0.16.0 → 0.18.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/api.d.ts +26 -4
- package/lib/api.js +108 -11
- package/lib/errors.d.ts +3 -0
- package/lib/errors.js +6 -0
- package/lib/index.d.ts +3 -2
- package/lib/index.js +1 -0
- package/lib/migrations.js +5 -1
- package/lib/tables.d.ts +6 -1
- package/package.json +9 -9
package/lib/api.d.ts
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
|
-
import type { LogStore } from '@kokuin/controller';
|
|
2
|
-
import type
|
|
1
|
+
import type { LogStore, SignedEvent } from '@kokuin/controller';
|
|
2
|
+
import { type Adapter } from '@kubun/db-adapter';
|
|
3
3
|
import type { Kysely } from 'kysely';
|
|
4
|
-
import type { ControllerStoreTables } from './tables.js';
|
|
5
|
-
export type
|
|
4
|
+
import type { CapabilityVerifiedAt, ControllerStoreTables } from './tables.js';
|
|
5
|
+
export type ControllerRecord = {
|
|
6
|
+
log: Array<SignedEvent> | null;
|
|
7
|
+
forkGeneration: number | null;
|
|
8
|
+
forkBranches: Array<Array<SignedEvent>> | null;
|
|
9
|
+
/** Unix seconds each capability revoke first verified at, by its signed event digest. */
|
|
10
|
+
capabilityVerifiedAt: CapabilityVerifiedAt;
|
|
11
|
+
observedAt: Date;
|
|
12
|
+
};
|
|
13
|
+
export type ControllerRecordInput = Omit<ControllerRecord, 'observedAt'>;
|
|
14
|
+
export type ControllerStoreAPI = LogStore & {
|
|
15
|
+
getRecord(did: string): Promise<ControllerRecord | undefined>;
|
|
16
|
+
/** Locks the row on Postgres; SQLite is already serialised by the transaction. */
|
|
17
|
+
getRecordForUpdate(did: string): Promise<ControllerRecord | undefined>;
|
|
18
|
+
/** Whether this call inserted: false when a row for the DID already exists. */
|
|
19
|
+
insertRecordIfAbsent(did: string, record: ControllerRecordInput): Promise<boolean>;
|
|
20
|
+
putRecord(did: string, record: ControllerRecordInput): Promise<void>;
|
|
21
|
+
/** Bumps the observation time only. */
|
|
22
|
+
touch(did: string): Promise<void>;
|
|
23
|
+
/** Last explicit store write, including a locally seeded controller log. */
|
|
24
|
+
getObservedAt(did: string): Promise<Date | undefined>;
|
|
25
|
+
/** Throws `ControllerStoreSchemaOutdatedError` when the stored table predates this version. */
|
|
26
|
+
assertSchema(): Promise<void>;
|
|
27
|
+
};
|
|
6
28
|
export declare function createControllerStore(db: Kysely<ControllerStoreTables>, adapter: Adapter): ControllerStoreAPI;
|
package/lib/api.js
CHANGED
|
@@ -1,30 +1,127 @@
|
|
|
1
|
+
import { AbstractPostgresAdapter } from '@kubun/db-adapter';
|
|
2
|
+
import { ControllerStoreSchemaOutdatedError } from './errors.js';
|
|
1
3
|
// ParseJSONResultsPlugin usually parses the JSON column back to an object, but a
|
|
2
4
|
// value can still arrive as a string (adapter/driver dependent). Handle both.
|
|
3
|
-
function
|
|
5
|
+
function decodeJSON(value) {
|
|
6
|
+
if (value == null) return null;
|
|
4
7
|
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
5
8
|
}
|
|
9
|
+
function decodeLog(value) {
|
|
10
|
+
return decodeJSON(value);
|
|
11
|
+
}
|
|
12
|
+
function encodeRecord(adapter, did, record) {
|
|
13
|
+
// SQLite's `encodeJSON` stringifies, which would turn a null into the text "null".
|
|
14
|
+
const json = (value)=>value == null ? null : adapter.encodeJSON(value);
|
|
15
|
+
return {
|
|
16
|
+
did,
|
|
17
|
+
log: json(record.log),
|
|
18
|
+
fork_generation: record.forkGeneration,
|
|
19
|
+
fork_branches: json(record.forkBranches),
|
|
20
|
+
capability_verified_at: adapter.encodeJSON(record.capabilityVerifiedAt),
|
|
21
|
+
updated_at: adapter.encodeTimestamp(new Date())
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
async function readRecord(db, adapter, did, lock) {
|
|
25
|
+
let query = db.selectFrom('kubun_controller_logs').selectAll().where('did', '=', did);
|
|
26
|
+
if (lock && adapter instanceof AbstractPostgresAdapter) query = query.forUpdate();
|
|
27
|
+
const row = await query.executeTakeFirst();
|
|
28
|
+
if (row == null) return undefined;
|
|
29
|
+
return {
|
|
30
|
+
log: decodeJSON(row.log),
|
|
31
|
+
forkGeneration: row.fork_generation,
|
|
32
|
+
forkBranches: decodeJSON(row.fork_branches),
|
|
33
|
+
capabilityVerifiedAt: decodeJSON(row.capability_verified_at) ?? {},
|
|
34
|
+
observedAt: adapter.decodeTimestamp(row.updated_at ?? row.created_at)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// A migration key runs once, so a database that applied `0-init` before the
|
|
38
|
+
// fork or capability time columns existed is only visible by inspecting the
|
|
39
|
+
// table on each open.
|
|
40
|
+
// Only settled results are cached per database (every transaction builds its
|
|
41
|
+
// own store API, and the schema cannot change in-process without a reset). An
|
|
42
|
+
// in-flight check is never shared: one started on the base provider queues
|
|
43
|
+
// behind an open transaction on single-connection SQLite, so a transaction
|
|
44
|
+
// awaiting it would deadlock.
|
|
45
|
+
const checkedSchemas = new WeakSet();
|
|
46
|
+
const outdatedSchemas = new WeakSet();
|
|
47
|
+
async function checkSchema(db) {
|
|
48
|
+
const table = (await db.introspection.getTables()).find((candidate)=>candidate.name === 'kubun_controller_logs');
|
|
49
|
+
const outdated = table != null && (!table.columns.some((col)=>col.name === 'fork_generation') || !table.columns.some((col)=>col.name === 'capability_verified_at') || table.columns.find((col)=>col.name === 'log')?.isNullable === false);
|
|
50
|
+
if (outdated) throw new ControllerStoreSchemaOutdatedError();
|
|
51
|
+
}
|
|
6
52
|
export function createControllerStore(db, adapter) {
|
|
7
53
|
const api = {
|
|
8
54
|
async get (did) {
|
|
9
55
|
const row = await db.selectFrom('kubun_controller_logs').select('log').where('did', '=', did).executeTakeFirst();
|
|
10
|
-
return row == null ? undefined : decodeLog(row.log);
|
|
56
|
+
return row?.log == null ? undefined : decodeLog(row.log);
|
|
57
|
+
},
|
|
58
|
+
getRecord: (did)=>readRecord(db, adapter, did, false),
|
|
59
|
+
getRecordForUpdate: (did)=>readRecord(db, adapter, did, true),
|
|
60
|
+
async insertRecordIfAbsent (did, record) {
|
|
61
|
+
const result = await db.insertInto('kubun_controller_logs').values(encodeRecord(adapter, did, record)).onConflict((oc)=>oc.column('did').doNothing()).executeTakeFirst();
|
|
62
|
+
return Number(result.numInsertedOrUpdatedRows ?? 0) > 0;
|
|
63
|
+
},
|
|
64
|
+
async putRecord (did, record) {
|
|
65
|
+
const values = encodeRecord(adapter, did, record);
|
|
66
|
+
await db.insertInto('kubun_controller_logs').values(values).onConflict((oc)=>oc.column('did').doUpdateSet({
|
|
67
|
+
log: values.log,
|
|
68
|
+
fork_generation: values.fork_generation,
|
|
69
|
+
fork_branches: values.fork_branches,
|
|
70
|
+
capability_verified_at: values.capability_verified_at,
|
|
71
|
+
updated_at: values.updated_at
|
|
72
|
+
})).execute();
|
|
73
|
+
},
|
|
74
|
+
async touch (did) {
|
|
75
|
+
await db.updateTable('kubun_controller_logs').set({
|
|
76
|
+
updated_at: adapter.encodeTimestamp(new Date())
|
|
77
|
+
}).where('did', '=', did).execute();
|
|
11
78
|
},
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
79
|
+
async getObservedAt (did) {
|
|
80
|
+
const row = await db.selectFrom('kubun_controller_logs').select([
|
|
81
|
+
'created_at',
|
|
82
|
+
'updated_at'
|
|
83
|
+
]).where('did', '=', did).executeTakeFirst();
|
|
84
|
+
if (row == null) return undefined;
|
|
85
|
+
return adapter.decodeTimestamp(row.updated_at ?? row.created_at);
|
|
86
|
+
},
|
|
87
|
+
// The guard wrapping every method does the check.
|
|
88
|
+
async assertSchema () {},
|
|
89
|
+
// Kept for the `LogStore` contract and test seeding only. Plain last-writer-wins:
|
|
90
|
+
// it refuses no truncated or forked log and leaves fork and capability state
|
|
91
|
+
// alone. Every source write, and any log from an untrusted source such as a
|
|
92
|
+
// peer, goes through the engine's `ControllerHead.ingest`, the only
|
|
93
|
+
// arbitration path.
|
|
18
94
|
async set (did, log) {
|
|
19
95
|
const encoded = adapter.encodeJSON(log);
|
|
96
|
+
const observedAt = adapter.encodeTimestamp(new Date());
|
|
20
97
|
await db.insertInto('kubun_controller_logs').values({
|
|
21
98
|
did,
|
|
22
|
-
log: encoded
|
|
99
|
+
log: encoded,
|
|
100
|
+
updated_at: observedAt
|
|
23
101
|
}).onConflict((oc)=>oc.column('did').doUpdateSet((eb)=>({
|
|
24
102
|
log: eb.ref('excluded.log'),
|
|
25
|
-
updated_at:
|
|
103
|
+
updated_at: observedAt
|
|
26
104
|
}))).execute();
|
|
27
105
|
}
|
|
28
106
|
};
|
|
29
|
-
|
|
107
|
+
const guard = async ()=>{
|
|
108
|
+
if (outdatedSchemas.has(adapter)) throw new ControllerStoreSchemaOutdatedError();
|
|
109
|
+
if (checkedSchemas.has(adapter)) return;
|
|
110
|
+
try {
|
|
111
|
+
await checkSchema(db);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
// Only an outdated schema is permanent; anything else is retried next call.
|
|
114
|
+
if (error instanceof ControllerStoreSchemaOutdatedError) outdatedSchemas.add(adapter);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
checkedSchemas.add(adapter);
|
|
118
|
+
};
|
|
119
|
+
const methods = Object.entries(api).map(([name, method])=>[
|
|
120
|
+
name,
|
|
121
|
+
async (...args)=>{
|
|
122
|
+
await guard();
|
|
123
|
+
return method(...args);
|
|
124
|
+
}
|
|
125
|
+
]);
|
|
126
|
+
return Object.fromEntries(methods);
|
|
30
127
|
}
|
package/lib/errors.d.ts
ADDED
package/lib/errors.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export class ControllerStoreSchemaOutdatedError extends Error {
|
|
2
|
+
constructor(){
|
|
3
|
+
super('The controller store schema is outdated (kubun_controller_logs lacks fork or capability time columns): reset the database');
|
|
4
|
+
this.name = 'ControllerStoreSchemaOutdatedError';
|
|
5
|
+
}
|
|
6
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { StoreProvider } from '@kubun/db';
|
|
2
2
|
import type { ControllerStoreAPI } from './api.js';
|
|
3
|
-
export type { ControllerStoreAPI } from './api.js';
|
|
3
|
+
export type { ControllerRecord, ControllerStoreAPI } from './api.js';
|
|
4
4
|
export { createControllerStore } from './api.js';
|
|
5
5
|
export { controllerStoreDefinition } from './definition.js';
|
|
6
|
+
export { ControllerStoreSchemaOutdatedError } from './errors.js';
|
|
6
7
|
export declare const CONTROLLER_STORE: "controller";
|
|
7
8
|
export declare function getControllerStore(provider: StoreProvider): Promise<ControllerStoreAPI>;
|
|
8
|
-
export type { ControllerLogRow, ControllerLogTable, ControllerStoreTables, InsertControllerLog, } from './tables.js';
|
|
9
|
+
export type { CapabilityVerifiedAt, ControllerLogRow, ControllerLogTable, ControllerStoreTables, InsertControllerLog, } from './tables.js';
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { createControllerStore } from './api.js';
|
|
2
2
|
export { controllerStoreDefinition } from './definition.js';
|
|
3
|
+
export { ControllerStoreSchemaOutdatedError } from './errors.js';
|
|
3
4
|
export const CONTROLLER_STORE = 'controller';
|
|
4
5
|
export function getControllerStore(provider) {
|
|
5
6
|
return provider.getStore(CONTROLLER_STORE);
|
package/lib/migrations.js
CHANGED
|
@@ -4,7 +4,11 @@ export function getControllerMigrations(ctx) {
|
|
|
4
4
|
const init = {
|
|
5
5
|
async up (db) {
|
|
6
6
|
// One row per controller DID; `log` holds the signed event array verbatim.
|
|
7
|
-
|
|
7
|
+
// `fork_*` record a detected fork: the generation and each rival branch.
|
|
8
|
+
// `capability_verified_at` maps each capability-authorised revoke's signed
|
|
9
|
+
// event digest to the unix seconds its capability first verified at, so
|
|
10
|
+
// the log still folds once the capability expires.
|
|
11
|
+
await db.schema.createTable('kubun_controller_logs').ifNotExists().addColumn('did', t.text, (col)=>col.notNull()).addColumn('log', t.json).addColumn('fork_generation', 'integer').addColumn('fork_branches', t.json).addColumn('capability_verified_at', t.json, (col)=>col.notNull().defaultTo('{}')).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).addPrimaryKeyConstraint('pk_controller_logs', [
|
|
8
12
|
'did'
|
|
9
13
|
]).execute();
|
|
10
14
|
},
|
package/lib/tables.d.ts
CHANGED
|
@@ -2,9 +2,14 @@ import type { SignedEvent } from '@kokuin/controller';
|
|
|
2
2
|
import type { CreatedAtColumn, UpdatedAtColumn } from '@kubun/db-adapter';
|
|
3
3
|
import type { ColumnType, Insertable, Selectable } from 'kysely';
|
|
4
4
|
type JSONValueColumn<T> = ColumnType<T, T, T>;
|
|
5
|
+
/** Unix seconds each capability revoke first verified at, keyed by its signed event digest. */
|
|
6
|
+
export type CapabilityVerifiedAt = Record<string, number>;
|
|
5
7
|
export type ControllerLogTable = {
|
|
6
8
|
did: string;
|
|
7
|
-
log: JSONValueColumn<Array<SignedEvent
|
|
9
|
+
log: JSONValueColumn<Array<SignedEvent> | null>;
|
|
10
|
+
fork_generation: number | null;
|
|
11
|
+
fork_branches: JSONValueColumn<Array<Array<SignedEvent>> | null>;
|
|
12
|
+
capability_verified_at: ColumnType<CapabilityVerifiedAt, CapabilityVerifiedAt | undefined, CapabilityVerifiedAt>;
|
|
8
13
|
created_at: CreatedAtColumn;
|
|
9
14
|
updated_at: UpdatedAtColumn;
|
|
10
15
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/store-controller",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@kokuin/controller": "^0.1.0",
|
|
19
|
-
"@kubun/db": "^0.
|
|
20
|
-
"@kubun/db-adapter": "^0.
|
|
21
|
-
"kysely": "^0.29.
|
|
19
|
+
"@kubun/db": "^0.18.0",
|
|
20
|
+
"@kubun/db-adapter": "^0.18.0",
|
|
21
|
+
"kysely": "^0.29.7"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@kubun/db-better-sqlite": "^0.
|
|
25
|
-
"@kubun/db-postgres": "^0.
|
|
26
|
-
"@testcontainers/postgresql": "^12.1
|
|
24
|
+
"@kubun/db-better-sqlite": "^0.18.0",
|
|
25
|
+
"@kubun/db-postgres": "^0.18.0",
|
|
26
|
+
"@testcontainers/postgresql": "^12.2.1"
|
|
27
27
|
},
|
|
28
28
|
"publishConfig": {
|
|
29
29
|
"access": "public"
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
33
33
|
"build:clean": "del lib",
|
|
34
|
-
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
35
|
-
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
34
|
+
"build:js": "del 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
35
|
+
"build:types": "del 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
|
|
36
36
|
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
37
37
|
"test": "pnpm run test:types && pnpm run test:unit",
|
|
38
38
|
"test:types": "tsc --noEmit -p tsconfig.test.json",
|