@kubun/db 0.16.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 +111 -5
- package/lib/db.js +219 -37
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/package.json +9 -7
package/lib/db.d.ts
CHANGED
|
@@ -1,18 +1,98 @@
|
|
|
1
|
-
import type
|
|
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
|
|
45
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
281
|
+
return await fn(createScopedProvider(commitHooks, rollbackHooks));
|
|
102
282
|
});
|
|
103
283
|
} catch (err) {
|
|
104
284
|
// Kysely issues ROLLBACK and rejects `execute()` when the callback
|
|
@@ -107,9 +287,9 @@ export class KubunDB {
|
|
|
107
287
|
// (revert first, then drop the stale cache). Hooks are isolated/logged,
|
|
108
288
|
// never re-thrown; the original error is preserved so callers (e.g. the
|
|
109
289
|
// engine's `MutateGraphWriteRollback` sentinel) still observe it.
|
|
110
|
-
for(
|
|
290
|
+
for (const [index, hook] of rollbackHooks.entries()){
|
|
111
291
|
try {
|
|
112
|
-
|
|
292
|
+
hook();
|
|
113
293
|
} catch (error) {
|
|
114
294
|
this.#logger.error('rollback hook {index}/{count} failed', {
|
|
115
295
|
index,
|
|
@@ -124,9 +304,9 @@ export class KubunDB {
|
|
|
124
304
|
// effects (event emission, cache invalidation). A throwing hook must not
|
|
125
305
|
// skip the remaining hooks or make the caller observe a durable write as a
|
|
126
306
|
// failure, so each hook is isolated and its error logged, never re-thrown.
|
|
127
|
-
for(
|
|
307
|
+
for (const [index, hook] of commitHooks.entries()){
|
|
128
308
|
try {
|
|
129
|
-
|
|
309
|
+
hook();
|
|
130
310
|
} catch (error) {
|
|
131
311
|
this.#logger.error('commit hook {index}/{count} failed', {
|
|
132
312
|
index,
|
|
@@ -137,9 +317,14 @@ export class KubunDB {
|
|
|
137
317
|
}
|
|
138
318
|
return result;
|
|
139
319
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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.
|
|
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.
|
|
19
|
-
"@kubun/logger": "^0.
|
|
20
|
-
"kysely": "^0.29.
|
|
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.
|
|
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"
|
|
@@ -28,8 +30,8 @@
|
|
|
28
30
|
"scripts": {
|
|
29
31
|
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
30
32
|
"build:clean": "del lib",
|
|
31
|
-
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
32
|
-
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
33
|
+
"build:js": "del 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
34
|
+
"build:types": "del 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
|
|
33
35
|
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
34
36
|
"test": "pnpm run test:types && pnpm run test:unit",
|
|
35
37
|
"test:types": "tsc --noEmit -p tsconfig.test.json",
|