@kubun/db 0.9.0 → 0.11.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/db.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Adapter, ColumnTypes, Functions } from '@kubun/db-adapter';
2
2
  import { type Logger } from '@kubun/logger';
3
- import { Kysely, type Migration } from 'kysely';
3
+ import { Kysely } from 'kysely';
4
+ import { type Migration } from 'kysely/migration';
4
5
  export type MigrationContext = {
5
6
  types: ColumnTypes;
6
7
  functions: Functions;
@@ -13,7 +14,24 @@ export type StoreDefinition<Tables, API> = {
13
14
  };
14
15
  export type StoreProvider<Stores extends Record<string, unknown> = Record<string, unknown>> = {
15
16
  getStore<S extends keyof Stores & string>(name: S): Promise<Stores[S]>;
17
+ /**
18
+ * Returns whether a store definition is registered, synchronously and
19
+ * WITHOUT triggering migration. This distinguishes "plugin not installed"
20
+ * (registration absent) from "plugin installed but its store currently
21
+ * errors" — callers can fail closed on the latter instead of mistaking a
22
+ * transient store error for an uninstalled plugin.
23
+ */
24
+ hasStore(name: string): boolean;
16
25
  onCommit(fn: () => void): void;
26
+ /**
27
+ * Register a hook to run after the transaction rolls back. By the time the
28
+ * hook fires the DB is already reverted, so it is the correct place to drop
29
+ * any in-memory state that was eagerly advanced in anticipation of a commit
30
+ * (e.g. cache invalidation). Like `onCommit`, hooks are isolated and their
31
+ * errors logged, never re-thrown. On the non-transactional provider it is a
32
+ * no-op (each standalone op auto-commits; nothing to roll back).
33
+ */
34
+ onRollback(fn: () => void): void;
17
35
  /**
18
36
  * Run `fn` against a `StoreProvider` that scopes its writes to a transaction.
19
37
  *
@@ -39,7 +57,9 @@ export declare class KubunDB implements StoreProvider {
39
57
  get adapter(): Adapter;
40
58
  register<Tables, API>(store: StoreDefinition<Tables, API>): void;
41
59
  getStore<T>(name: string): Promise<T>;
60
+ hasStore(name: string): boolean;
42
61
  onCommit(fn: () => void): void;
62
+ onRollback(_fn: () => void): void;
43
63
  /**
44
64
  * Runs `fn` inside a DB transaction and provides a scoped `StoreProvider`.
45
65
  * Concurrent calls on the same `KubunDB` instance are supported — the
package/lib/db.js CHANGED
@@ -1 +1,186 @@
1
- import{getKubunLogger as t}from"@kubun/logger";import{Kysely as e,Migrator as r,ParseJSONResultsPlugin as i}from"kysely";export class KubunDB{#t;#e;#r;#i=new Map;#s=new Map;#a=new Map;constructor(r){this.#t=r.adapter,this.#e=new e({dialect:this.#t.dialect,plugins:[new i]}),this.#r=r.logger??t("db")}get adapter(){return this.#t}register(t){let e=this.#a.get(t.name);if(null!=e){e!==t&&this.#r.warn('store "{name}" registered with different definition, ignoring',{name:t.name});return}this.#a.set(t.name,t)}async getStore(t){if(await this.#o(t),!this.#s.has(t)){let e=this.#a.get(t);if(null==e)throw Error(`Store "${t}" is not registered`);this.#s.set(t,e.createAPI(this.#e,this.#t))}return this.#s.get(t)}onCommit(t){t()}async withTransaction(t){await Promise.all(Array.from(this.#a.keys()).map(t=>this.#o(t)));let e=[],r=await this.#e.transaction().execute(async r=>{let i=new Map,s={getStore:async t=>{if(!i.has(t)){let e=this.#a.get(t);if(null==e)throw Error(`Store "${t}" is not registered`);i.set(t,e.createAPI(r,this.#t))}return i.get(t)},onCommit:t=>{e.push(t)},withTransaction:async t=>await t(s)};return await t(s)});for(let t of e)t();return r}async close(){await this.#e.destroy(),this.#r.info("closed")}async #o(t){let e=this.#i.get(t);return null==e&&(e=this.#n(t).catch(e=>{throw this.#i.delete(t),e}),this.#i.set(t,e)),e}async #n(t){let e=this.#a.get(t);if(null==e)throw Error(`Store "${t}" is not registered`);for(let t of e.dependsOn??[])await this.#o(t);let i="function"==typeof e.migrations?e.migrations({types:this.#t.types,functions:this.#t.functions}):e.migrations;if(0===Object.keys(i).length)return;let s=new r({db:this.#e,provider:{getMigrations:()=>Promise.resolve(i)},migrationTableName:`kubun_${t}_migration`,migrationLockTableName:`kubun_${t}_migration_lock`}),a=await s.migrateToLatest();if(null!=a.error)throw a.error;this.#r.info("store {name} migrations complete",{name:t})}}
1
+ import { getKubunLogger } from '@kubun/logger';
2
+ import { Kysely, ParseJSONResultsPlugin } from 'kysely';
3
+ import { Migrator } from 'kysely/migration';
4
+ export class KubunDB {
5
+ #adapter;
6
+ #db;
7
+ #logger;
8
+ #migrated = new Map();
9
+ #storeAPIs = new Map();
10
+ #stores = new Map();
11
+ constructor(params){
12
+ this.#adapter = params.adapter;
13
+ this.#db = new Kysely({
14
+ dialect: this.#adapter.dialect,
15
+ plugins: [
16
+ new ParseJSONResultsPlugin()
17
+ ]
18
+ });
19
+ this.#logger = params.logger ?? getKubunLogger('db');
20
+ }
21
+ get adapter() {
22
+ return this.#adapter;
23
+ }
24
+ register(store) {
25
+ const existing = this.#stores.get(store.name);
26
+ if (existing != null) {
27
+ if (existing !== store) {
28
+ this.#logger.warn('store "{name}" registered with different definition, ignoring', {
29
+ name: store.name
30
+ });
31
+ }
32
+ return;
33
+ }
34
+ this.#stores.set(store.name, store);
35
+ }
36
+ async getStore(name) {
37
+ await this.#ensureMigrated(name);
38
+ if (!this.#storeAPIs.has(name)) {
39
+ const store = this.#stores.get(name);
40
+ if (store == null) {
41
+ throw new Error(`Store "${name}" is not registered`);
42
+ }
43
+ this.#storeAPIs.set(name, store.createAPI(this.#db, this.#adapter));
44
+ }
45
+ return this.#storeAPIs.get(name);
46
+ }
47
+ hasStore(name) {
48
+ return this.#stores.has(name);
49
+ }
50
+ onCommit(fn) {
51
+ fn();
52
+ }
53
+ // Each standalone op auto-commits, so there is nothing to roll back — no-op.
54
+ onRollback(_fn) {}
55
+ /**
56
+ * Runs `fn` inside a DB transaction and provides a scoped `StoreProvider`.
57
+ * Concurrent calls on the same `KubunDB` instance are supported — the
58
+ * underlying driver serializes them.
59
+ *
60
+ * Callers MUST use the provided `tx` inside the callback. Calling
61
+ * `db.withTransaction` on the same instance inside the callback opens a
62
+ * new independent transaction (no atomic nesting) and is a bug.
63
+ */ async withTransaction(fn) {
64
+ // Migrate every registered store before opening the transaction. Stores
65
+ // are internal and known up front, so a broken migration should fail fast
66
+ // at startup rather than leave a runtime partition where one store's
67
+ // failure silently blocks transactions that never touch it.
68
+ await Promise.all(Array.from(this.#stores.keys()).map((name)=>this.#ensureMigrated(name)));
69
+ const commitHooks = [];
70
+ const rollbackHooks = [];
71
+ let result;
72
+ try {
73
+ result = await this.#db.transaction().execute(async (trx)=>{
74
+ const txKysely = trx;
75
+ const txStoreAPIs = new Map();
76
+ const provider = {
77
+ getStore: async (name)=>{
78
+ if (!txStoreAPIs.has(name)) {
79
+ const store = this.#stores.get(name);
80
+ if (store == null) {
81
+ throw new Error(`Store "${name}" is not registered`);
82
+ }
83
+ txStoreAPIs.set(name, store.createAPI(txKysely, this.#adapter));
84
+ }
85
+ return txStoreAPIs.get(name);
86
+ },
87
+ hasStore: (name)=>this.#stores.has(name),
88
+ onCommit: (hook)=>{
89
+ commitHooks.push(hook);
90
+ },
91
+ onRollback: (hook)=>{
92
+ rollbackHooks.push(hook);
93
+ },
94
+ withTransaction: async (nestedFn)=>{
95
+ // Already inside a transaction — Kysely does not nest.
96
+ // Run `nestedFn` inline with the same provider so a throw rolls
97
+ // the outer transaction back.
98
+ return await nestedFn(provider);
99
+ }
100
+ };
101
+ return await fn(provider);
102
+ });
103
+ } catch (err) {
104
+ // Kysely issues ROLLBACK and rejects `execute()` when the callback
105
+ // throws, so the DB is already reverted here. Run the rollback hooks to
106
+ // drop any in-memory state that was eagerly advanced expecting a commit
107
+ // (revert first, then drop the stale cache). Hooks are isolated/logged,
108
+ // never re-thrown; the original error is preserved so callers (e.g. the
109
+ // engine's `MutateGraphWriteRollback` sentinel) still observe it.
110
+ for(let index = 0; index < rollbackHooks.length; index++){
111
+ try {
112
+ rollbackHooks[index]();
113
+ } catch (error) {
114
+ this.#logger.error('rollback hook {index}/{count} failed', {
115
+ index,
116
+ count: rollbackHooks.length,
117
+ error
118
+ });
119
+ }
120
+ }
121
+ throw err;
122
+ }
123
+ // The transaction has committed; post-commit hooks are best-effort side
124
+ // effects (event emission, cache invalidation). A throwing hook must not
125
+ // skip the remaining hooks or make the caller observe a durable write as a
126
+ // failure, so each hook is isolated and its error logged, never re-thrown.
127
+ for(let index = 0; index < commitHooks.length; index++){
128
+ try {
129
+ commitHooks[index]();
130
+ } catch (error) {
131
+ this.#logger.error('commit hook {index}/{count} failed', {
132
+ index,
133
+ count: commitHooks.length,
134
+ error
135
+ });
136
+ }
137
+ }
138
+ return result;
139
+ }
140
+ async close() {
141
+ await this.#db.destroy();
142
+ this.#logger.info('closed');
143
+ }
144
+ async #ensureMigrated(name) {
145
+ let pending = this.#migrated.get(name);
146
+ if (pending == null) {
147
+ pending = this.#runStoreMigrations(name).catch((err)=>{
148
+ this.#migrated.delete(name);
149
+ throw err;
150
+ });
151
+ this.#migrated.set(name, pending);
152
+ }
153
+ return pending;
154
+ }
155
+ async #runStoreMigrations(name) {
156
+ const store = this.#stores.get(name);
157
+ if (store == null) {
158
+ throw new Error(`Store "${name}" is not registered`);
159
+ }
160
+ for (const dep of store.dependsOn ?? []){
161
+ await this.#ensureMigrated(dep);
162
+ }
163
+ const migrations = typeof store.migrations === 'function' ? store.migrations({
164
+ types: this.#adapter.types,
165
+ functions: this.#adapter.functions
166
+ }) : store.migrations;
167
+ if (Object.keys(migrations).length === 0) {
168
+ return;
169
+ }
170
+ const migrator = new Migrator({
171
+ db: this.#db,
172
+ provider: {
173
+ getMigrations: ()=>Promise.resolve(migrations)
174
+ },
175
+ migrationTableName: `kubun_${name}_migration`,
176
+ migrationLockTableName: `kubun_${name}_migration_lock`
177
+ });
178
+ const result = await migrator.migrateToLatest();
179
+ if (result.error != null) {
180
+ throw result.error;
181
+ }
182
+ this.#logger.info('store {name} migrations complete', {
183
+ name
184
+ });
185
+ }
186
+ }
package/lib/index.js CHANGED
@@ -1 +1 @@
1
- export{KubunDB}from"./db.js";
1
+ export { KubunDB } from './db.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/db",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "license": "see LICENSE.md",
5
5
  "keywords": [],
6
6
  "type": "module",
@@ -15,16 +15,16 @@
15
15
  ],
16
16
  "sideEffects": false,
17
17
  "dependencies": {
18
- "kysely": "^0.28.17",
19
- "@kubun/db-adapter": "^0.9.0",
20
- "@kubun/logger": "^0.9.0"
18
+ "kysely": "^0.29.2",
19
+ "@kubun/db-adapter": "^0.11.0",
20
+ "@kubun/logger": "^0.11.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@kubun/db-better-sqlite": "^0.9.0"
23
+ "@kubun/db-better-sqlite": "^0.11.0"
24
24
  },
25
25
  "scripts": {
26
26
  "build:clean": "del lib",
27
- "build:js": "swc src -d ./lib --config-file ../../swc.json --strip-leading-paths",
27
+ "build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
28
28
  "build:types": "tsc --emitDeclarationOnly --skipLibCheck",
29
29
  "build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
30
30
  "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",