@kubun/db 0.17.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/db.d.ts CHANGED
@@ -1,18 +1,98 @@
1
- import type { Adapter, ColumnTypes, Functions } from '@kubun/db-adapter';
1
+ import { type Adapter, type ColumnTypes, type Functions } from '@kubun/db-adapter';
2
2
  import { type Logger } from '@kubun/logger';
3
3
  import { Kysely } from 'kysely';
4
4
  import { type Migration } from 'kysely/migration';
5
+ /** The SQL dialect family an adapter speaks. */
6
+ export type MigrationDialect = 'postgres' | 'sqlite';
5
7
  export type MigrationContext = {
6
8
  types: ColumnTypes;
7
9
  functions: Functions;
10
+ dialect: MigrationDialect;
8
11
  };
12
+ /** The migration context for an adapter, as KubunDB passes it to store migrations. */
13
+ export declare function getMigrationContext(adapter: Adapter): MigrationContext;
9
14
  export type StoreDefinition<Tables, API> = {
10
15
  name: string;
11
16
  migrations: Record<string, Migration> | ((ctx: MigrationContext) => Record<string, Migration>);
12
17
  dependsOn?: Array<string>;
13
- createAPI: (db: Kysely<Tables>, adapter: Adapter) => API;
18
+ createAPI: (db: Kysely<Tables>, adapter: Adapter, context?: StoreAPIContext) => API;
14
19
  };
20
+ export type StoreAPIContext = {
21
+ provider: StoreProvider;
22
+ root: StoreProvider;
23
+ getCatalogRouteRules(nowMS: number, catalog?: CatalogRef): Promise<CatalogRouteRulesInput>;
24
+ /** Route rules read through the root provider: safe for work that outlives a transaction. */
25
+ getRootCatalogRouteRules(nowMS: number, catalog?: CatalogRef): Promise<CatalogRouteRulesInput>;
26
+ onClose?: (fn: () => Promise<void> | void) => void;
27
+ /** Bound to `provider`: tells the document bytes listeners, in the caller's transaction. */
28
+ documentBytes: {
29
+ needed(documentID: string): Promise<void>;
30
+ released(documentID: string): Promise<void>;
31
+ };
32
+ /** Bound to `provider`: tells the document applied listeners, in the caller's transaction. */
33
+ documentApplied(event: DocumentAppliedEvent): Promise<void>;
34
+ /** Absent until custody pin hooks are registered, and then bound to `provider`. */
35
+ custody?: {
36
+ guards(): Promise<ReadonlyArray<CustodyGuard>>;
37
+ keepsOwner(custodyOwner: string, documentOwner: string): Promise<boolean>;
38
+ /** Call once a queued custody scan is committed. */
39
+ scansQueued(): void;
40
+ };
41
+ /** Call once a stored custody assignment or acceptance is committed. */
42
+ custodyRecordsChanged?(): void;
43
+ };
44
+ /** A catalog's address: its owner DID and the owner-chosen catalog ID. */
45
+ export type CatalogRef = {
46
+ owner: string;
47
+ id: string;
48
+ };
49
+ /** A catalog this device has accepted to guard for a custody owner. */
50
+ export type CustodyGuard = {
51
+ catalog: CatalogRef;
52
+ custodyOwner: string;
53
+ };
54
+ export type CustodyPinHooks = {
55
+ /** The custodies this device currently guards, read through `stores`. */
56
+ guards(stores: StoreProvider): Promise<ReadonlyArray<CustodyGuard>>;
57
+ /** Whether the custody owner keeps documents of this owner: own or unresolved. */
58
+ keepsOwner(stores: StoreProvider, custodyOwner: string, documentOwner: string): Promise<boolean>;
59
+ /** Wakes whatever runs queued custody scans, so a scan need not wait for the next start. */
60
+ scansQueued?(): void;
61
+ };
62
+ /**
63
+ * Runs in the transaction that recomputes a document's refs. `needed`: a custody pin or a
64
+ * `SERVE_ONLY` catalog keeps the live document, so this device keeps its attachment bytes.
65
+ * `released`: the document held such a ref and holds none now.
66
+ */
67
+ export type DocumentBytesListener = {
68
+ needed(stores: StoreProvider, documentID: string): Promise<void>;
69
+ released(stores: StoreProvider, documentID: string): Promise<void>;
70
+ };
71
+ /** A document's data was written in the transaction; `authored`: this device wrote it. */
72
+ export type DocumentAppliedEvent = {
73
+ documentID: string;
74
+ authored: boolean;
75
+ };
76
+ /** Runs in the transaction that writes a document's data; a throw rolls that write back. */
77
+ export type DocumentAppliedListener = (stores: StoreProvider, event: DocumentAppliedEvent) => Promise<void>;
78
+ export type CatalogRouteRulesInput = ReadonlyArray<{
79
+ catalog: CatalogRef;
80
+ rules: ReadonlyArray<{
81
+ id: string;
82
+ models?: Array<string>;
83
+ owners?: Array<string>;
84
+ maxAge?: string;
85
+ maxAhead?: string;
86
+ dateField?: string;
87
+ }>;
88
+ }>;
15
89
  export type StoreProvider<Stores extends Record<string, unknown> = Record<string, unknown>> = {
90
+ readonly isTransactionBound: boolean;
91
+ /**
92
+ * The outermost transaction's provider, shared by its savepoint scopes. Lets
93
+ * per-transaction state survive a savepoint boundary. Absent outside a transaction.
94
+ */
95
+ readonly transactionRoot?: StoreProvider;
16
96
  getStore<S extends keyof Stores & string>(name: S): Promise<Stores[S]>;
17
97
  /**
18
98
  * Returns whether a store definition is registered, synchronously and
@@ -41,20 +121,46 @@ export type StoreProvider<Stores extends Record<string, unknown> = Record<string
41
121
  * On a transactional `tx` provider obtained from a parent
42
122
  * `withTransaction` call, this method runs `fn` inline with `this` as the
43
123
  * nested `tx`. The underlying driver does not nest transactions
44
- * (no savepoints), so atomicity is inherited from the outer transaction
45
- * — a throw in `fn` rolls the whole outer transaction back, exactly the
46
- * semantics callers expect for "register + bind atomically".
124
+ * (no implicit savepoints), so atomicity is inherited from the enclosing
125
+ * transaction or explicit savepoint.
47
126
  */
48
127
  withTransaction<NestedStores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
128
+ /** Roll back only this unit of work when called inside a transaction. */
129
+ withSavepoint?<NestedStores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
49
130
  };
131
+ /**
132
+ * Thrown when a scope opens a savepoint while one of its savepoints is still
133
+ * open. Run sequential savepoints one after another, and nest through the
134
+ * provider the open savepoint passes to its callback.
135
+ */
136
+ export declare class SavepointOverlapError extends Error {
137
+ constructor();
138
+ }
50
139
  export type KubunDBParams = {
51
140
  adapter: Adapter;
52
141
  logger?: Logger;
53
142
  };
54
143
  export declare class KubunDB implements StoreProvider {
55
144
  #private;
145
+ get isTransactionBound(): boolean;
56
146
  constructor(params: KubunDBParams);
57
147
  get adapter(): Adapter;
148
+ setCatalogRouteRulesProvider(provider: (stores: StoreProvider, nowMS: number, catalog?: CatalogRef) => Promise<CatalogRouteRulesInput>): void;
149
+ /**
150
+ * Register the check a local catalog rule change must pass before it commits:
151
+ * whatever routes the catalog decides which limits apply. Called inside the
152
+ * write's transaction, so a throw rolls the change back.
153
+ */
154
+ setCatalogRuleWriteValidator(validator: (stores: StoreProvider, catalog: CatalogRef) => Promise<void>): void;
155
+ /** Register how ref recompute learns which custodies pin documents here. */
156
+ setCustodyPinHooks(hooks: CustodyPinHooks): void;
157
+ /** A throwing listener rolls back the transaction that recomputed the document's refs. */
158
+ addDocumentBytesListener(listener: DocumentBytesListener): () => void;
159
+ /** A throwing listener rolls back the transaction that wrote the document's data. */
160
+ addDocumentAppliedListener(listener: DocumentAppliedListener): () => void;
161
+ /** Called after a custody assignment or acceptance write commits; errors are logged. */
162
+ addCustodyRecordsListener(listener: () => void): () => void;
163
+ validateCatalogRuleWrite(stores: StoreProvider, catalog: CatalogRef): Promise<void>;
58
164
  register<Tables, API>(store: StoreDefinition<Tables, API>): void;
59
165
  getStore<T>(name: string): Promise<T>;
60
166
  hasStore(name: string): boolean;
package/lib/db.js CHANGED
@@ -1,13 +1,42 @@
1
+ import { AbstractPostgresAdapter } from '@kubun/db-adapter';
1
2
  import { getKubunLogger } from '@kubun/logger';
2
- import { Kysely, ParseJSONResultsPlugin } from 'kysely';
3
+ import { Kysely, ParseJSONResultsPlugin, sql } from 'kysely';
3
4
  import { Migrator } from 'kysely/migration';
5
+ /** The migration context for an adapter, as KubunDB passes it to store migrations. */ export function getMigrationContext(adapter) {
6
+ return {
7
+ types: adapter.types,
8
+ functions: adapter.functions,
9
+ dialect: adapter instanceof AbstractPostgresAdapter ? 'postgres' : 'sqlite'
10
+ };
11
+ }
12
+ /**
13
+ * Thrown when a scope opens a savepoint while one of its savepoints is still
14
+ * open. Run sequential savepoints one after another, and nest through the
15
+ * provider the open savepoint passes to its callback.
16
+ */ export class SavepointOverlapError extends Error {
17
+ constructor(){
18
+ super('A savepoint is already open in this transaction scope');
19
+ this.name = 'SavepointOverlapError';
20
+ }
21
+ }
4
22
  export class KubunDB {
23
+ get isTransactionBound() {
24
+ return false;
25
+ }
5
26
  #adapter;
6
27
  #db;
7
28
  #logger;
8
29
  #migrated = new Map();
9
30
  #storeAPIs = new Map();
10
31
  #stores = new Map();
32
+ #closeHooks = new Set();
33
+ #closePromise = null;
34
+ #catalogRouteRulesProvider = null;
35
+ #catalogRuleWriteValidator = null;
36
+ #custodyPinHooks = null;
37
+ #documentBytesListeners = new Set();
38
+ #documentAppliedListeners = new Set();
39
+ #custodyRecordsListeners = new Set();
11
40
  constructor(params){
12
41
  this.#adapter = params.adapter;
13
42
  this.#db = new Kysely({
@@ -21,6 +50,81 @@ export class KubunDB {
21
50
  get adapter() {
22
51
  return this.#adapter;
23
52
  }
53
+ setCatalogRouteRulesProvider(provider) {
54
+ this.#catalogRouteRulesProvider = provider;
55
+ }
56
+ /**
57
+ * Register the check a local catalog rule change must pass before it commits:
58
+ * whatever routes the catalog decides which limits apply. Called inside the
59
+ * write's transaction, so a throw rolls the change back.
60
+ */ setCatalogRuleWriteValidator(validator) {
61
+ this.#catalogRuleWriteValidator = validator;
62
+ }
63
+ /** Register how ref recompute learns which custodies pin documents here. */ setCustodyPinHooks(hooks) {
64
+ this.#custodyPinHooks = hooks;
65
+ }
66
+ /** A throwing listener rolls back the transaction that recomputed the document's refs. */ addDocumentBytesListener(listener) {
67
+ this.#documentBytesListeners.add(listener);
68
+ return ()=>{
69
+ this.#documentBytesListeners.delete(listener);
70
+ };
71
+ }
72
+ /** A throwing listener rolls back the transaction that wrote the document's data. */ addDocumentAppliedListener(listener) {
73
+ this.#documentAppliedListeners.add(listener);
74
+ return ()=>{
75
+ this.#documentAppliedListeners.delete(listener);
76
+ };
77
+ }
78
+ /** Called after a custody assignment or acceptance write commits; errors are logged. */ addCustodyRecordsListener(listener) {
79
+ this.#custodyRecordsListeners.add(listener);
80
+ return ()=>{
81
+ this.#custodyRecordsListeners.delete(listener);
82
+ };
83
+ }
84
+ #custodyRecordsChanged() {
85
+ for (const listener of this.#custodyRecordsListeners){
86
+ try {
87
+ listener();
88
+ } catch (error) {
89
+ this.#logger.warn('custody records listener failed', {
90
+ error
91
+ });
92
+ }
93
+ }
94
+ }
95
+ #documentBytesContext(stores) {
96
+ return {
97
+ needed: async (documentID)=>{
98
+ for (const listener of this.#documentBytesListeners)await listener.needed(stores, documentID);
99
+ },
100
+ released: async (documentID)=>{
101
+ for (const listener of this.#documentBytesListeners)await listener.released(stores, documentID);
102
+ }
103
+ };
104
+ }
105
+ #documentAppliedContext(stores) {
106
+ return async (event)=>{
107
+ for (const listener of this.#documentAppliedListeners)await listener(stores, event);
108
+ };
109
+ }
110
+ #custodyContext(stores) {
111
+ const hooks = this.#custodyPinHooks;
112
+ if (hooks == null) return undefined;
113
+ return {
114
+ guards: ()=>hooks.guards(stores),
115
+ keepsOwner: (custodyOwner, documentOwner)=>hooks.keepsOwner(stores, custodyOwner, documentOwner),
116
+ scansQueued: ()=>{
117
+ hooks.scansQueued?.();
118
+ }
119
+ };
120
+ }
121
+ async validateCatalogRuleWrite(stores, catalog) {
122
+ await this.#catalogRuleWriteValidator?.(stores, catalog);
123
+ }
124
+ async #getCatalogRouteRules(stores, nowMS, catalog) {
125
+ const rules = await this.#catalogRouteRulesProvider?.(stores, nowMS, catalog) ?? [];
126
+ return catalog == null ? rules : rules.filter((entry)=>entry.catalog.owner === catalog.owner && entry.catalog.id === catalog.id);
127
+ }
24
128
  register(store) {
25
129
  const existing = this.#stores.get(store.name);
26
130
  if (existing != null) {
@@ -40,7 +144,21 @@ export class KubunDB {
40
144
  if (store == null) {
41
145
  throw new Error(`Store "${name}" is not registered`);
42
146
  }
43
- this.#storeAPIs.set(name, store.createAPI(this.#db, this.#adapter));
147
+ const custodyContext = ()=>this.#custodyContext(this);
148
+ this.#storeAPIs.set(name, store.createAPI(this.#db, this.#adapter, {
149
+ provider: this,
150
+ root: this,
151
+ getCatalogRouteRules: (nowMS, catalog)=>this.#getCatalogRouteRules(this, nowMS, catalog),
152
+ getRootCatalogRouteRules: (nowMS, catalog)=>this.#getCatalogRouteRules(this, nowMS, catalog),
153
+ onClose: (fn)=>this.#closeHooks.add(fn),
154
+ documentBytes: this.#documentBytesContext(this),
155
+ documentApplied: this.#documentAppliedContext(this),
156
+ custodyRecordsChanged: ()=>this.#custodyRecordsChanged(),
157
+ // Read when called, so hooks registered after the store was created still apply.
158
+ get custody () {
159
+ return custodyContext();
160
+ }
161
+ }));
44
162
  }
45
163
  return this.#storeAPIs.get(name);
46
164
  }
@@ -69,36 +187,98 @@ export class KubunDB {
69
187
  const commitHooks = [];
70
188
  const rollbackHooks = [];
71
189
  let result;
190
+ let savepointID = 0;
72
191
  try {
73
192
  result = await this.#db.transaction().execute(async (trx)=>{
74
193
  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`);
194
+ let rootProvider;
195
+ const createScopedProvider = (scopedCommitHooks, scopedRollbackHooks)=>{
196
+ // Savepoints on one connection form a stack: a scope holds at most
197
+ // one open savepoint, and nesting goes through that savepoint's
198
+ // own provider.
199
+ let savepointOpen = false;
200
+ const scopedStoreAPIs = new Map();
201
+ const provider = {
202
+ isTransactionBound: true,
203
+ get transactionRoot () {
204
+ return rootProvider;
205
+ },
206
+ getStore: async (name)=>{
207
+ if (!scopedStoreAPIs.has(name)) {
208
+ const store = this.#stores.get(name);
209
+ if (store == null) {
210
+ throw new Error(`Store "${name}" is not registered`);
211
+ }
212
+ const custodyContext = ()=>this.#custodyContext(provider);
213
+ scopedStoreAPIs.set(name, store.createAPI(txKysely, this.#adapter, {
214
+ provider,
215
+ root: this,
216
+ getCatalogRouteRules: (nowMS, catalog)=>this.#getCatalogRouteRules(provider, nowMS, catalog),
217
+ getRootCatalogRouteRules: (nowMS, catalog)=>this.#getCatalogRouteRules(this, nowMS, catalog),
218
+ onClose: (fn)=>this.#closeHooks.add(fn),
219
+ documentBytes: this.#documentBytesContext(provider),
220
+ documentApplied: this.#documentAppliedContext(provider),
221
+ custodyRecordsChanged: ()=>this.#custodyRecordsChanged(),
222
+ get custody () {
223
+ return custodyContext();
224
+ }
225
+ }));
226
+ }
227
+ return scopedStoreAPIs.get(name);
228
+ },
229
+ hasStore: (name)=>this.#stores.has(name),
230
+ onCommit: (hook)=>{
231
+ scopedCommitHooks.push(hook);
232
+ },
233
+ onRollback: (hook)=>{
234
+ scopedRollbackHooks.push(hook);
235
+ },
236
+ withTransaction: async (nestedFn)=>{
237
+ // Already inside a transaction — Kysely does not nest.
238
+ // Run `nestedFn` inline with the same provider so its hooks and
239
+ // writes remain within the enclosing transaction or savepoint.
240
+ return await nestedFn(provider);
241
+ },
242
+ withSavepoint: async (nestedFn)=>{
243
+ if (savepointOpen) throw new SavepointOverlapError();
244
+ savepointOpen = true;
245
+ try {
246
+ const name = `kubun_sp_${++savepointID}`;
247
+ const savepointCommitHooks = [];
248
+ const savepointRollbackHooks = [];
249
+ const scopedProvider = createScopedProvider(savepointCommitHooks, savepointRollbackHooks);
250
+ await sql.raw(`SAVEPOINT ${name}`).execute(trx);
251
+ try {
252
+ const value = await nestedFn(scopedProvider);
253
+ await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
254
+ scopedCommitHooks.push(...savepointCommitHooks);
255
+ scopedRollbackHooks.push(...savepointRollbackHooks);
256
+ return value;
257
+ } catch (error) {
258
+ await sql.raw(`ROLLBACK TO SAVEPOINT ${name}`).execute(trx);
259
+ await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
260
+ for (const [index, hook] of savepointRollbackHooks.entries()){
261
+ try {
262
+ hook();
263
+ } catch (hookError) {
264
+ this.#logger.error('rollback hook {index}/{count} failed', {
265
+ index,
266
+ count: savepointRollbackHooks.length,
267
+ error: hookError
268
+ });
269
+ }
270
+ }
271
+ throw error;
272
+ }
273
+ } finally{
274
+ savepointOpen = false;
82
275
  }
83
- txStoreAPIs.set(name, store.createAPI(txKysely, this.#adapter));
84
276
  }
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
- }
277
+ };
278
+ rootProvider ??= provider;
279
+ return provider;
100
280
  };
101
- return await fn(provider);
281
+ return await fn(createScopedProvider(commitHooks, rollbackHooks));
102
282
  });
103
283
  } catch (err) {
104
284
  // Kysely issues ROLLBACK and rejects `execute()` when the callback
@@ -137,9 +317,14 @@ export class KubunDB {
137
317
  }
138
318
  return result;
139
319
  }
140
- async close() {
141
- await this.#db.destroy();
142
- this.#logger.info('closed');
320
+ close() {
321
+ this.#closePromise ??= Promise.resolve().then(async ()=>{
322
+ for (const hook of this.#closeHooks)await hook();
323
+ this.#closeHooks.clear();
324
+ await this.#db.destroy();
325
+ this.#logger.info('closed');
326
+ });
327
+ return this.#closePromise;
143
328
  }
144
329
  async #ensureMigrated(name) {
145
330
  let pending = this.#migrated.get(name);
@@ -160,10 +345,7 @@ export class KubunDB {
160
345
  for (const dep of store.dependsOn ?? []){
161
346
  await this.#ensureMigrated(dep);
162
347
  }
163
- const migrations = typeof store.migrations === 'function' ? store.migrations({
164
- types: this.#adapter.types,
165
- functions: this.#adapter.functions
166
- }) : store.migrations;
348
+ const migrations = typeof store.migrations === 'function' ? store.migrations(getMigrationContext(this.#adapter)) : store.migrations;
167
349
  if (Object.keys(migrations).length === 0) {
168
350
  return;
169
351
  }
package/lib/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { KubunDB, type KubunDBParams, type MigrationContext, type StoreDefinition, type StoreProvider, } from './db.js';
1
+ export { type CatalogRef, type CustodyGuard, type CustodyPinHooks, type DocumentAppliedEvent, type DocumentAppliedListener, type DocumentBytesListener, getMigrationContext, KubunDB, type KubunDBParams, type MigrationContext, type MigrationDialect, SavepointOverlapError, type StoreAPIContext, type StoreDefinition, type StoreProvider, } from './db.js';
package/lib/index.js CHANGED
@@ -1 +1 @@
1
- export { KubunDB } from './db.js';
1
+ export { getMigrationContext, KubunDB, SavepointOverlapError } from './db.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/db",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -15,12 +15,14 @@
15
15
  "LICENSE.md"
16
16
  ],
17
17
  "dependencies": {
18
- "@kubun/db-adapter": "^0.17.0",
19
- "@kubun/logger": "^0.17.0",
20
- "kysely": "^0.29.5"
18
+ "@kubun/db-adapter": "^0.18.0",
19
+ "@kubun/logger": "^0.18.0",
20
+ "kysely": "^0.29.7"
21
21
  },
22
22
  "devDependencies": {
23
- "@kubun/db-better-sqlite": "^0.17.0"
23
+ "@kubun/db-better-sqlite": "^0.18.0",
24
+ "@kubun/db-postgres": "^0.18.0",
25
+ "@testcontainers/postgresql": "^12.2.1"
24
26
  },
25
27
  "publishConfig": {
26
28
  "access": "public"