@kokuin/store-controller 0.0.0-stage → 0.2.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/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # @kokuin/store-controller
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Hozon-backed store for `did:kokuin:` controller key event logs.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install @kokuin/store-controller
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { HozonDB } from '@hozon/db'
15
+ import { authoritativeLog } from '@kokuin/controller'
16
+ import { controllerStoreDefinition, getControllerStore } from '@kokuin/store-controller'
17
+
18
+ const db = new HozonDB({ adapter }) // an @hozon/node-sqlite or @hozon/postgres adapter
19
+ db.register(controllerStoreDefinition)
20
+ const logs = await getControllerStore(db)
21
+
22
+ // Merge an untrusted presentation through the same arbitration the resolver uses.
23
+ // Never write peer bytes (or a `mergeLogs` union) directly: stored events of the
24
+ // current epoch are not re-verified, so a forged one would be applied.
25
+ await logs.merge(
26
+ did,
27
+ async (seen) => authoritativeLog({ did, loaded: presented, seen, context: 'sync' }).log,
28
+ )
29
+ const stored = await logs.get(did)
30
+ ```
31
+
32
+ See [docs/reference/stores.md](../../docs/reference/stores.md).
package/lib/api.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { Adapter } from '@hozon/adapter';
2
+ import { type Kysely } from '@hozon/db';
3
+ import { type LogStore } from '@kokuin/controller';
4
+ import type { ControllerStoreTables } from './tables.js';
5
+ export type ControllerStoreAPI = LogStore & {
6
+ /** Last explicit store write, including a locally seeded controller log. */
7
+ getObservedAt(did: string): Promise<Date | undefined>;
8
+ };
9
+ export declare function createControllerStore(db: Kysely<ControllerStoreTables>, adapter: Adapter): ControllerStoreAPI;
package/lib/api.js ADDED
@@ -0,0 +1,95 @@
1
+ import { withStoreTransaction } from '@hozon/db';
2
+ import { isControllerLog } from '@kokuin/controller';
3
+ // ParseJSONResultsPlugin usually parses the JSON column back to an object, but a
4
+ // value can still arrive as a string (adapter/driver dependent). Handle both. A
5
+ // row written before the log split into chain and epoch events is a bare event
6
+ // array: it is that chain, with no epoch events. Anything else is not a log and
7
+ // is no memory, as the resolver treats a stored value of that shape -- a string
8
+ // that does not parse included: throwing would refuse every later `get` and
9
+ // `merge` for the DID, where no memory costs one round of the guarantee and the
10
+ // next merge replaces the row.
11
+ function decodeLog(value) {
12
+ let parsed = value;
13
+ if (typeof value === 'string') {
14
+ try {
15
+ parsed = JSON.parse(value);
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ if (Array.isArray(parsed)) {
21
+ return {
22
+ chain: parsed,
23
+ events: []
24
+ };
25
+ }
26
+ return isControllerLog(parsed) ? parsed : undefined;
27
+ }
28
+ export function createControllerStore(db, adapter) {
29
+ const api = {
30
+ async get (did) {
31
+ const row = await db.selectFrom('controller_logs').select('log').where('did', '=', did).executeTakeFirst();
32
+ return row == null ? undefined : decodeLog(row.log);
33
+ },
34
+ async getObservedAt (did) {
35
+ const row = await db.selectFrom('controller_logs').select([
36
+ 'created_at',
37
+ 'updated_at'
38
+ ]).where('did', '=', did).executeTakeFirst();
39
+ if (row == null) return undefined;
40
+ return adapter.decodeTimestamp(row.updated_at ?? row.created_at);
41
+ },
42
+ // Writes whatever `update` returns, unless it hands back `current` itself:
43
+ // that is "nothing changed" (see `LogStore.merge`), and the row -- written
44
+ // or not, legacy shape or not -- is left alone. Arbitration lives in the resolver
45
+ // (`authoritativeLog`), never here. A caller ingesting an UNTRUSTED log (e.g.
46
+ // one pulled from a peer) must build the update from `authoritativeLog` —
47
+ // this store will not refuse a truncated or forked log on its own, and the
48
+ // resolver does not re-verify the current epoch's stored events. Writing
49
+ // peer bytes directly would bypass the anti-truncation guarantee AND let a
50
+ // forged event of the current epoch be applied as authentic.
51
+ // The read and the write share a store transaction, which joins an enclosing
52
+ // one. Postgres locks the row on read (`for update`), so a concurrent merge
53
+ // waits and then reads this one's result. A DID with no row has nothing to
54
+ // lock: both merges would see none, so the insert is made `do nothing` and a
55
+ // lost race (no row inserted) re-reads, now locking the row the winner wrote.
56
+ // SQLite has no row locks: in one process the single connection's mutex
57
+ // serialises merges; across processes a stale deferred read fails with
58
+ // SQLITE_BUSY (or SQLITE_BUSY_SNAPSHOT) on write, so nothing is lost and the
59
+ // merge throws.
60
+ async merge (did, update) {
61
+ return await withStoreTransaction(db, async (trx)=>{
62
+ const lock = adapter.kind === 'postgres';
63
+ const values = (next)=>({
64
+ did,
65
+ log: adapter.encodeJSON(next),
66
+ updated_at: adapter.encodeTimestamp(new Date())
67
+ });
68
+ const write = async (next)=>{
69
+ await trx.insertInto('controller_logs').values(values(next)).onConflict((oc)=>oc.column('did').doUpdateSet((eb)=>({
70
+ log: eb.ref('excluded.log'),
71
+ updated_at: eb.ref('excluded.updated_at')
72
+ }))).execute();
73
+ };
74
+ let query = trx.selectFrom('controller_logs').select('log').where('did', '=', did);
75
+ if (lock) query = query.forUpdate();
76
+ const row = await query.executeTakeFirst();
77
+ if (row != null || !lock) {
78
+ const current = row == null ? undefined : decodeLog(row.log);
79
+ const next = await update(current);
80
+ if (next !== current) await write(next);
81
+ return next;
82
+ }
83
+ const first = await update(undefined);
84
+ const inserted = await trx.insertInto('controller_logs').values(values(first)).onConflict((oc)=>oc.column('did').doNothing()).executeTakeFirst();
85
+ if (Number(inserted.numInsertedOrUpdatedRows ?? 0) > 0) return first;
86
+ const raced = await query.executeTakeFirst();
87
+ const current = raced == null ? undefined : decodeLog(raced.log);
88
+ const next = await update(current);
89
+ if (next !== current) await write(next);
90
+ return next;
91
+ });
92
+ }
93
+ };
94
+ return api;
95
+ }
@@ -0,0 +1,6 @@
1
+ import type { StoreDefinition, StoreProvider } from '@hozon/db';
2
+ import type { ControllerStoreAPI } from './api.js';
3
+ import type { ControllerStoreTables } from './tables.js';
4
+ export declare const CONTROLLER_STORE: "controller";
5
+ export declare const controllerStoreDefinition: StoreDefinition<ControllerStoreTables, ControllerStoreAPI>;
6
+ export declare function getControllerStore(provider: StoreProvider): Promise<ControllerStoreAPI>;
@@ -0,0 +1,11 @@
1
+ import { createControllerStore } from './api.js';
2
+ import { getControllerMigrations } from './migrations.js';
3
+ export const CONTROLLER_STORE = 'controller';
4
+ export const controllerStoreDefinition = {
5
+ name: CONTROLLER_STORE,
6
+ migrations: getControllerMigrations,
7
+ createAPI: createControllerStore
8
+ };
9
+ export async function getControllerStore(provider) {
10
+ return provider.getStore(CONTROLLER_STORE);
11
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type { ControllerStoreAPI } from './api.js';
2
+ export { createControllerStore } from './api.js';
3
+ export { CONTROLLER_STORE, controllerStoreDefinition, getControllerStore, } from './definition.js';
4
+ export type { ControllerLogRow, ControllerLogTable, ControllerStoreTables, InsertControllerLog, } from './tables.js';
package/lib/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createControllerStore } from './api.js';
2
+ export { CONTROLLER_STORE, controllerStoreDefinition, getControllerStore } from './definition.js';
@@ -0,0 +1,2 @@
1
+ import type { Migration, MigrationContext } from '@hozon/db';
2
+ export declare function getControllerMigrations(ctx: MigrationContext): Record<string, Migration>;
@@ -0,0 +1,18 @@
1
+ export function getControllerMigrations(ctx) {
2
+ const t = ctx.types;
3
+ const now = ctx.functions.now;
4
+ const init = {
5
+ async up (db) {
6
+ // One row per controller DID; `log` holds the signed event array verbatim.
7
+ await db.schema.createTable('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(`${ctx.tablePrefix}_controller_logs_pkey`, [
8
+ 'did'
9
+ ]).execute();
10
+ },
11
+ async down (db) {
12
+ await db.schema.dropTable('controller_logs').ifExists().execute();
13
+ }
14
+ };
15
+ return {
16
+ '0-init': init
17
+ };
18
+ }
@@ -0,0 +1,16 @@
1
+ import type { CreatedAtColumn, UpdatedAtColumn } from '@hozon/adapter';
2
+ import type { ColumnType, Insertable, Selectable } from '@hozon/db';
3
+ import type { ControllerLog } from '@kokuin/controller';
4
+ type JSONValueColumn<T> = ColumnType<T, T, T>;
5
+ export type ControllerLogTable = {
6
+ did: string;
7
+ log: JSONValueColumn<ControllerLog>;
8
+ created_at: CreatedAtColumn;
9
+ updated_at: UpdatedAtColumn;
10
+ };
11
+ export type ControllerLogRow = Selectable<ControllerLogTable>;
12
+ export type InsertControllerLog = Insertable<ControllerLogTable>;
13
+ export type ControllerStoreTables = {
14
+ controller_logs: ControllerLogTable;
15
+ };
16
+ export {};
package/lib/tables.js ADDED
@@ -0,0 +1 @@
1
+ export { };
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@kokuin/store-controller",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Hozon-backed controller log store for did:kokuin",
5
+ "keywords": [
6
+ "did",
7
+ "controller",
8
+ "store"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/TairuFramework/kokuin",
13
+ "directory": "packages/store-controller"
14
+ },
15
+ "license": "MIT",
16
+ "sideEffects": false,
17
+ "type": "module",
18
+ "exports": {
19
+ ".": "./lib/index.js"
20
+ },
21
+ "main": "lib/index.js",
22
+ "types": "lib/index.d.ts",
23
+ "files": [
24
+ "lib/*"
25
+ ],
26
+ "dependencies": {
27
+ "@hozon/adapter": "^0.1.0",
28
+ "@hozon/db": "^0.3.0",
29
+ "@kokuin/controller": "^0.2.0"
30
+ },
31
+ "devDependencies": {
32
+ "@hozon/node-sqlite": "^0.1.0",
33
+ "@types/node": "^26.6.5"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
40
+ "build:clean": "del-cli lib",
41
+ "build:js": "del-cli 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
42
+ "build:types": "del-cli 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
43
+ "test": "pnpm run test:types && pnpm run test:unit",
44
+ "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
45
+ "test:unit": "vitest run"
46
+ }
6
47
  }