@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 +31 -2
- package/lib/api.d.ts +9 -0
- package/lib/api.js +95 -0
- package/lib/definition.d.ts +6 -0
- package/lib/definition.js +11 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +2 -0
- package/lib/migrations.d.ts +2 -0
- package/lib/migrations.js +18 -0
- package/lib/tables.d.ts +16 -0
- package/lib/tables.js +1 -0
- package/package.json +44 -3
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kokuin/store-controller
|
|
2
2
|
|
|
3
|
-
|
|
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,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
|
+
}
|
package/lib/tables.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|