@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 CHANGED
@@ -1,6 +1,28 @@
1
- import type { LogStore } from '@kokuin/controller';
2
- import type { Adapter } from '@kubun/db-adapter';
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 ControllerStoreAPI = LogStore;
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 decodeLog(value) {
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
- // Plain last-writer-wins: `set` is called only after the log has folded, so
13
- // arbitration lives in the resolver, never here. See `@kokuin/controller`.
14
- // A caller ingesting an UNTRUSTED log (e.g. one pulled from a peer) must run
15
- // it through the resolver's `authoritativeStates`/`history` guard first — this
16
- // store will not refuse a truncated or forked log on its own, so a direct
17
- // `set` of peer bytes would bypass the anti-truncation guarantee.
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: adapter.encodeTimestamp(new Date())
103
+ updated_at: observedAt
26
104
  }))).execute();
27
105
  }
28
106
  };
29
- return api;
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
  }
@@ -0,0 +1,3 @@
1
+ export declare class ControllerStoreSchemaOutdatedError extends Error {
2
+ constructor();
3
+ }
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
- await db.schema.createTable('kubun_controller_logs').ifNotExists().addColumn('did', t.text, (col)=>col.notNull()).addColumn('log', t.json, (col)=>col.notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).addPrimaryKeyConstraint('pk_controller_logs', [
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.16.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.16.0",
20
- "@kubun/db-adapter": "^0.16.0",
21
- "kysely": "^0.29.5"
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.16.0",
25
- "@kubun/db-postgres": "^0.16.0",
26
- "@testcontainers/postgresql": "^12.1.0"
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",