@kokuin/store-controller 0.1.0 → 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
@@ -12,13 +12,20 @@ npm install @kokuin/store-controller
12
12
 
13
13
  ```typescript
14
14
  import { HozonDB } from '@hozon/db'
15
+ import { authoritativeLog } from '@kokuin/controller'
15
16
  import { controllerStoreDefinition, getControllerStore } from '@kokuin/store-controller'
16
17
 
17
18
  const db = new HozonDB({ adapter }) // an @hozon/node-sqlite or @hozon/postgres adapter
18
19
  db.register(controllerStoreDefinition)
19
20
  const logs = await getControllerStore(db)
20
21
 
21
- await logs.set(did, log) // after the log has folded
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
+ )
22
29
  const stored = await logs.get(did)
23
30
  ```
24
31
 
package/lib/api.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Adapter } from '@hozon/adapter';
2
- import type { Kysely } from '@hozon/db';
3
- import type { LogStore } from '@kokuin/controller';
2
+ import { type Kysely } from '@hozon/db';
3
+ import { type LogStore } from '@kokuin/controller';
4
4
  import type { ControllerStoreTables } from './tables.js';
5
5
  export type ControllerStoreAPI = LogStore & {
6
6
  /** Last explicit store write, including a locally seeded controller log. */
package/lib/api.js CHANGED
@@ -1,7 +1,29 @@
1
+ import { withStoreTransaction } from '@hozon/db';
2
+ import { isControllerLog } from '@kokuin/controller';
1
3
  // ParseJSONResultsPlugin usually parses the JSON column back to an object, but a
2
- // value can still arrive as a string (adapter/driver dependent). Handle both.
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.
3
11
  function decodeLog(value) {
4
- return typeof value === 'string' ? JSON.parse(value) : 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;
5
27
  }
6
28
  export function createControllerStore(db, adapter) {
7
29
  const api = {
@@ -17,23 +39,56 @@ export function createControllerStore(db, adapter) {
17
39
  if (row == null) return undefined;
18
40
  return adapter.decodeTimestamp(row.updated_at ?? row.created_at);
19
41
  },
20
- // Plain last-writer-wins: `set` is called only after the log has folded, so
21
- // arbitration lives in the resolver, never here. See `@kokuin/controller`.
22
- // A caller ingesting an UNTRUSTED log (e.g. one pulled from a peer) must run
23
- // it through the resolver's `authoritativeStates`/`history` guard first — this
24
- // store will not refuse a truncated or forked log on its own, so a direct
25
- // `set` of peer bytes would bypass the anti-truncation guarantee.
26
- async set (did, log) {
27
- const encoded = adapter.encodeJSON(log);
28
- const observedAt = adapter.encodeTimestamp(new Date());
29
- await db.insertInto('controller_logs').values({
30
- did,
31
- log: encoded,
32
- updated_at: observedAt
33
- }).onConflict((oc)=>oc.column('did').doUpdateSet((eb)=>({
34
- log: eb.ref('excluded.log'),
35
- updated_at: observedAt
36
- }))).execute();
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
+ });
37
92
  }
38
93
  };
39
94
  return api;
package/lib/tables.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import type { CreatedAtColumn, UpdatedAtColumn } from '@hozon/adapter';
2
2
  import type { ColumnType, Insertable, Selectable } from '@hozon/db';
3
- import type { SignedEvent } from '@kokuin/controller';
3
+ import type { ControllerLog } from '@kokuin/controller';
4
4
  type JSONValueColumn<T> = ColumnType<T, T, T>;
5
5
  export type ControllerLogTable = {
6
6
  did: string;
7
- log: JSONValueColumn<Array<SignedEvent>>;
7
+ log: JSONValueColumn<ControllerLog>;
8
8
  created_at: CreatedAtColumn;
9
9
  updated_at: UpdatedAtColumn;
10
10
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kokuin/store-controller",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Hozon-backed controller log store for did:kokuin",
5
5
  "keywords": [
6
6
  "did",
@@ -25,12 +25,12 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "@hozon/adapter": "^0.1.0",
28
- "@hozon/db": "^0.2.2",
29
- "@kokuin/controller": "^0.1.0"
28
+ "@hozon/db": "^0.3.0",
29
+ "@kokuin/controller": "^0.2.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@hozon/node-sqlite": "^0.1.0",
33
- "@types/node": "^26.6.4"
33
+ "@types/node": "^26.6.5"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"