@hozon/db 0.3.0 → 0.5.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 +36 -8
- package/lib/db.js +114 -22
- package/lib/errors.d.ts +9 -0
- package/lib/errors.js +18 -0
- package/lib/index.d.ts +4 -3
- package/lib/index.js +3 -2
- package/lib/keep-set.d.ts +4 -1
- package/lib/keep-set.js +9 -3
- package/lib/preflight.d.ts +2 -0
- package/lib/preflight.js +10 -5
- package/lib/sync.d.ts +29 -0
- package/lib/sync.js +188 -0
- package/package.json +3 -3
package/lib/db.d.ts
CHANGED
|
@@ -3,16 +3,31 @@ import { type Logger } from '@sozai/log';
|
|
|
3
3
|
import { Kysely } from 'kysely';
|
|
4
4
|
import { type Migration } from 'kysely/migration';
|
|
5
5
|
import type { BatchLoader, BatchLoaderParams } from './batch-loader.js';
|
|
6
|
+
import { type CompareDefinition } from './sync.js';
|
|
6
7
|
export type MigrationContext = {
|
|
7
8
|
tablePrefix: string;
|
|
8
9
|
kind: Adapter['kind'];
|
|
9
10
|
types: ColumnTypes;
|
|
10
11
|
functions: Functions;
|
|
11
12
|
};
|
|
12
|
-
export type
|
|
13
|
+
export type BaseStoreContext = {
|
|
14
|
+
compareWrite<T extends string>(table: T, row: Record<string, unknown>): Promise<{
|
|
15
|
+
changed: boolean;
|
|
16
|
+
}>;
|
|
13
17
|
createLoader<TKey, TValue>(params: BatchLoaderParams<TKey, TValue>): BatchLoader<TKey, TValue>;
|
|
18
|
+
provider: StoreProvider;
|
|
19
|
+
root: StoreProvider;
|
|
20
|
+
onClose(fn: () => Promise<void> | void): void;
|
|
14
21
|
};
|
|
15
|
-
|
|
22
|
+
type ContextExtension = object & {
|
|
23
|
+
[Key in keyof BaseStoreContext]?: never;
|
|
24
|
+
};
|
|
25
|
+
export type StoreContext<Ext extends ContextExtension = {}> = BaseStoreContext & Ext;
|
|
26
|
+
export type ContextFactory<Ext extends ContextExtension> = (scope: {
|
|
27
|
+
provider: StoreProvider;
|
|
28
|
+
root: StoreProvider;
|
|
29
|
+
}) => keyof Ext & keyof BaseStoreContext extends never ? Ext : never;
|
|
30
|
+
export type StoreDefinition<Tables, API, Ext extends ContextExtension = {}> = {
|
|
16
31
|
name: string;
|
|
17
32
|
migrations: Record<string, Migration> | ((ctx: MigrationContext) => Record<string, Migration>);
|
|
18
33
|
dependsOn?: Array<string>;
|
|
@@ -22,9 +37,15 @@ export type StoreDefinition<Tables, API> = {
|
|
|
22
37
|
* tables are not namespaced: the store must keep them collision-free.
|
|
23
38
|
*/
|
|
24
39
|
unprefixedTables?: (name: string) => boolean;
|
|
25
|
-
|
|
40
|
+
sync?: {
|
|
41
|
+
[T in keyof Tables & string]?: CompareDefinition<Tables[T]>;
|
|
42
|
+
};
|
|
43
|
+
createAPI: (db: Kysely<Tables>, adapter: Adapter, context: StoreContext<Ext>) => API;
|
|
26
44
|
};
|
|
27
45
|
export type StoreProvider<Stores extends Record<string, unknown> = Record<string, unknown>> = {
|
|
46
|
+
readonly isTransactionBound: boolean;
|
|
47
|
+
/** Identity of the outermost transaction provider, shared by savepoints. Absent outside transactions. */
|
|
48
|
+
readonly transactionRoot?: StoreProvider;
|
|
28
49
|
getStore<S extends keyof Stores & string>(name: S): Promise<Stores[S]>;
|
|
29
50
|
/**
|
|
30
51
|
* Returns whether a store definition is registered, synchronously and
|
|
@@ -61,16 +82,22 @@ export type StoreProvider<Stores extends Record<string, unknown> = Record<string
|
|
|
61
82
|
/** Roll back only this unit of work when called inside a transaction. */
|
|
62
83
|
withSavepoint?<NestedStores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
|
|
63
84
|
};
|
|
64
|
-
export type HozonDBParams = {
|
|
85
|
+
export type HozonDBParams<Ext extends ContextExtension = {}> = {
|
|
65
86
|
adapter: Adapter;
|
|
66
87
|
logger?: Logger;
|
|
67
88
|
tablePrefix?: string;
|
|
68
|
-
}
|
|
69
|
-
|
|
89
|
+
} & (keyof Ext extends never ? {
|
|
90
|
+
context?: ContextFactory<Ext>;
|
|
91
|
+
} : {
|
|
92
|
+
context: ContextFactory<Ext>;
|
|
93
|
+
});
|
|
94
|
+
export declare class HozonDB<Ext extends ContextExtension = {}> implements StoreProvider {
|
|
70
95
|
#private;
|
|
71
|
-
constructor(params: HozonDBParams);
|
|
96
|
+
constructor(params: HozonDBParams<Ext>);
|
|
72
97
|
get adapter(): Adapter;
|
|
73
|
-
|
|
98
|
+
get isTransactionBound(): boolean;
|
|
99
|
+
get transactionRoot(): StoreProvider | undefined;
|
|
100
|
+
register<Tables, API>(store: StoreDefinition<Tables, API, Ext>): void;
|
|
74
101
|
getStore<T>(name: string): Promise<T>;
|
|
75
102
|
hasStore(name: string): boolean;
|
|
76
103
|
onCommit(fn: () => void): void;
|
|
@@ -89,3 +116,4 @@ export declare class HozonDB implements StoreProvider {
|
|
|
89
116
|
migrate(): Promise<void>;
|
|
90
117
|
close(): Promise<void>;
|
|
91
118
|
}
|
|
119
|
+
export {};
|
package/lib/db.js
CHANGED
|
@@ -2,11 +2,15 @@ import { getLogger } from '@sozai/log';
|
|
|
2
2
|
import { Kysely, sql } from 'kysely';
|
|
3
3
|
import { Migrator } from 'kysely/migration';
|
|
4
4
|
import { withTransactionalDDL } from './dialect.js';
|
|
5
|
-
import { HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError } from './errors.js';
|
|
5
|
+
import { HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SavepointReleasedError, StoreAlreadyRegisteredError } from './errors.js';
|
|
6
6
|
import { LoaderRegistry } from './loader-registry.js';
|
|
7
|
-
import { checkStore } from './preflight.js';
|
|
7
|
+
import { checkStore, readAppliedMigrations } from './preflight.js';
|
|
8
|
+
import { bindSyncProvider, compareWrite, settleSyncProvider } from './sync.js';
|
|
8
9
|
import { TablePrefixPlugin } from './table-prefix.js';
|
|
10
|
+
// biome-ignore lint/complexity/noBannedTypes: The default adds no context fields.
|
|
9
11
|
export class HozonDB {
|
|
12
|
+
#context;
|
|
13
|
+
#closeHooks = [];
|
|
10
14
|
#tablePrefix;
|
|
11
15
|
#defaultPlugin;
|
|
12
16
|
#closed = false;
|
|
@@ -26,6 +30,7 @@ export class HozonDB {
|
|
|
26
30
|
#storeAPIs = new Map();
|
|
27
31
|
#stores = new Map();
|
|
28
32
|
constructor(params){
|
|
33
|
+
this.#context = params.context;
|
|
29
34
|
this.#tablePrefix = params.tablePrefix ?? 'hozon';
|
|
30
35
|
if (!/^[a-z][a-z0-9_]{0,30}$/.test(this.#tablePrefix)) {
|
|
31
36
|
throw new InvalidTablePrefixError(this.#tablePrefix);
|
|
@@ -45,13 +50,17 @@ export class HozonDB {
|
|
|
45
50
|
get adapter() {
|
|
46
51
|
return this.#adapter;
|
|
47
52
|
}
|
|
53
|
+
get isTransactionBound() {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
get transactionRoot() {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
48
59
|
register(store) {
|
|
49
60
|
const existing = this.#stores.get(store.name);
|
|
50
61
|
if (existing != null) {
|
|
51
62
|
if (existing !== store) {
|
|
52
|
-
|
|
53
|
-
name: store.name
|
|
54
|
-
});
|
|
63
|
+
throw new StoreAlreadyRegisteredError(store.name);
|
|
55
64
|
}
|
|
56
65
|
return;
|
|
57
66
|
}
|
|
@@ -67,7 +76,7 @@ export class HozonDB {
|
|
|
67
76
|
if (store == null) {
|
|
68
77
|
throw new Error(`Store "${name}" is not registered`);
|
|
69
78
|
}
|
|
70
|
-
this.#storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, this.#loaders));
|
|
79
|
+
this.#storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, this.#loaders, this));
|
|
71
80
|
}
|
|
72
81
|
return this.#storeAPIs.get(name);
|
|
73
82
|
}
|
|
@@ -86,6 +95,7 @@ export class HozonDB {
|
|
|
86
95
|
});
|
|
87
96
|
const storeAPIs = new Map();
|
|
88
97
|
const provider = {
|
|
98
|
+
isTransactionBound: false,
|
|
89
99
|
getStore: async (name)=>{
|
|
90
100
|
this.#assertOpen();
|
|
91
101
|
await this.#ensurePreflight();
|
|
@@ -94,7 +104,7 @@ export class HozonDB {
|
|
|
94
104
|
if (!storeAPIs.has(name)) {
|
|
95
105
|
const store = this.#stores.get(name);
|
|
96
106
|
if (store == null) throw new Error(`Store "${name}" is not registered`);
|
|
97
|
-
storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, registry));
|
|
107
|
+
storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, registry, provider));
|
|
98
108
|
}
|
|
99
109
|
return storeAPIs.get(name);
|
|
100
110
|
},
|
|
@@ -144,70 +154,114 @@ export class HozonDB {
|
|
|
144
154
|
result = await this.#db.transaction().execute(async (trx)=>{
|
|
145
155
|
const txKysely = trx;
|
|
146
156
|
const txStoreDB = txKysely.withPlugin(this.#defaultPlugin);
|
|
147
|
-
const
|
|
148
|
-
|
|
157
|
+
const createScopedProvider = (scopedCommitHooks, scopedRollbackHooks, transactionRoot, syncParent, syncSavepoint = false, scopeState = {
|
|
158
|
+
savepointOpen: false,
|
|
159
|
+
settled: false,
|
|
160
|
+
savepointBound: syncSavepoint
|
|
161
|
+
})=>{
|
|
149
162
|
// Savepoints on one connection form a stack: a scope holds at most
|
|
150
163
|
// one open savepoint, and nesting goes through that savepoint's
|
|
151
164
|
// own provider.
|
|
152
|
-
|
|
165
|
+
const assertActive = ()=>{
|
|
166
|
+
if (scopeState.settled) throw new SavepointReleasedError();
|
|
167
|
+
};
|
|
168
|
+
const txStoreAPIs = new Map();
|
|
153
169
|
const provider = {
|
|
170
|
+
isTransactionBound: true,
|
|
171
|
+
get transactionRoot () {
|
|
172
|
+
return transactionRoot ?? provider;
|
|
173
|
+
},
|
|
154
174
|
getStore: async (name)=>{
|
|
175
|
+
assertActive();
|
|
176
|
+
this.#assertOpen();
|
|
155
177
|
if (!txStoreAPIs.has(name)) {
|
|
156
178
|
const store = this.#stores.get(name);
|
|
157
179
|
if (store == null) {
|
|
158
180
|
throw new Error(`Store "${name}" is not registered`);
|
|
159
181
|
}
|
|
160
|
-
txStoreAPIs.set(name, this.#createStoreAPI(store, txKysely, txStoreDB, registry));
|
|
182
|
+
txStoreAPIs.set(name, this.#createStoreAPI(store, txKysely, txStoreDB, registry, provider, scopeState.savepointBound ? assertActive : undefined));
|
|
161
183
|
}
|
|
162
184
|
return txStoreAPIs.get(name);
|
|
163
185
|
},
|
|
164
|
-
hasStore: (name)=>
|
|
186
|
+
hasStore: (name)=>{
|
|
187
|
+
assertActive();
|
|
188
|
+
return this.#stores.has(name);
|
|
189
|
+
},
|
|
165
190
|
onCommit: (hook)=>{
|
|
191
|
+
assertActive();
|
|
166
192
|
scopedCommitHooks.push(hook);
|
|
167
193
|
},
|
|
168
194
|
onRollback: (hook)=>{
|
|
195
|
+
assertActive();
|
|
169
196
|
scopedRollbackHooks.push(hook);
|
|
170
197
|
},
|
|
171
198
|
withScope: async (nestedFn)=>{
|
|
199
|
+
assertActive();
|
|
172
200
|
return await nestedFn(provider);
|
|
173
201
|
},
|
|
174
202
|
withTransaction: async (nestedFn)=>{
|
|
175
203
|
// Already inside a transaction — Kysely does not nest.
|
|
176
204
|
// Run `nestedFn` inline with the same provider so its hooks and
|
|
177
205
|
// writes remain within the enclosing transaction or savepoint.
|
|
206
|
+
assertActive();
|
|
178
207
|
return await nestedFn(provider);
|
|
179
208
|
},
|
|
180
209
|
withSavepoint: async (nestedFn)=>{
|
|
181
|
-
|
|
182
|
-
savepointOpen
|
|
210
|
+
assertActive();
|
|
211
|
+
if (scopeState.savepointOpen) throw new SavepointOverlapError();
|
|
212
|
+
scopeState.savepointOpen = true;
|
|
183
213
|
try {
|
|
184
214
|
const name = `${this.#tablePrefix}_sp_${++savepointID}`;
|
|
185
215
|
const savepointCommitHooks = [];
|
|
186
216
|
const savepointRollbackHooks = [];
|
|
187
|
-
const
|
|
217
|
+
const savepointState = {
|
|
218
|
+
savepointOpen: false,
|
|
219
|
+
settled: false,
|
|
220
|
+
savepointBound: true
|
|
221
|
+
};
|
|
222
|
+
const scopedProvider = createScopedProvider(savepointCommitHooks, savepointRollbackHooks, provider.transactionRoot, provider, true, savepointState);
|
|
188
223
|
registry.dispatchAll();
|
|
189
224
|
await sql.raw(`SAVEPOINT ${name}`).execute(trx);
|
|
190
225
|
try {
|
|
191
226
|
const value = await nestedFn(scopedProvider);
|
|
192
227
|
registry.dispatchAll();
|
|
193
228
|
await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
|
|
194
|
-
|
|
195
|
-
|
|
229
|
+
savepointState.settled = true;
|
|
230
|
+
registry.clearAll();
|
|
231
|
+
for (const hook of savepointCommitHooks)provider.onCommit(hook);
|
|
232
|
+
for (const hook of savepointRollbackHooks)provider.onRollback(hook);
|
|
196
233
|
return value;
|
|
197
234
|
} catch (error) {
|
|
198
235
|
registry.dispatchAll();
|
|
199
236
|
await sql.raw(`ROLLBACK TO SAVEPOINT ${name}`).execute(trx);
|
|
200
237
|
registry.dispatchAll();
|
|
201
238
|
await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
|
|
239
|
+
savepointState.settled = true;
|
|
202
240
|
registry.clearAll();
|
|
203
241
|
this.#runHooks(savepointRollbackHooks, 'rollback');
|
|
204
242
|
throw error;
|
|
243
|
+
} finally{
|
|
244
|
+
savepointState.settled = true;
|
|
245
|
+
settleSyncProvider(scopedProvider);
|
|
205
246
|
}
|
|
206
247
|
} finally{
|
|
207
|
-
savepointOpen = false;
|
|
248
|
+
scopeState.savepointOpen = false;
|
|
208
249
|
}
|
|
209
250
|
}
|
|
210
251
|
};
|
|
252
|
+
bindSyncProvider(provider, {
|
|
253
|
+
assertActive,
|
|
254
|
+
derive: ()=>createScopedProvider(scopedCommitHooks, scopedRollbackHooks, provider.transactionRoot, provider, false, scopeState),
|
|
255
|
+
db: txKysely,
|
|
256
|
+
kind: this.#adapter.kind,
|
|
257
|
+
tablePrefix: this.#tablePrefix,
|
|
258
|
+
tableName: (table)=>{
|
|
259
|
+
const unprefixed = [
|
|
260
|
+
...this.#stores.values()
|
|
261
|
+
].some((store)=>store.unprefixedTables?.(table));
|
|
262
|
+
return unprefixed ? table : `${this.#tablePrefix}_${table}`;
|
|
263
|
+
}
|
|
264
|
+
}, syncParent, syncSavepoint);
|
|
211
265
|
return provider;
|
|
212
266
|
};
|
|
213
267
|
try {
|
|
@@ -260,6 +314,17 @@ export class HozonDB {
|
|
|
260
314
|
return this.#closing;
|
|
261
315
|
}
|
|
262
316
|
async #close() {
|
|
317
|
+
// Publish #closing before a hook can re-enter close().
|
|
318
|
+
await Promise.resolve();
|
|
319
|
+
for (const hook of this.#closeHooks){
|
|
320
|
+
try {
|
|
321
|
+
await hook();
|
|
322
|
+
} catch (error) {
|
|
323
|
+
this.#logger.error('close hook failed', {
|
|
324
|
+
error
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
263
328
|
try {
|
|
264
329
|
await this.#db.destroy();
|
|
265
330
|
} finally{
|
|
@@ -294,13 +359,31 @@ export class HozonDB {
|
|
|
294
359
|
}
|
|
295
360
|
return migrations;
|
|
296
361
|
}
|
|
297
|
-
// Hands stores only createLoader, so they cannot reach close/dispatchAll/clearAll.
|
|
298
362
|
// Stores on the default plugin must share one Kysely instance: keep-set locks key on it.
|
|
299
|
-
#createStoreAPI(store, kysely, defaultDB, registry) {
|
|
363
|
+
#createStoreAPI(store, kysely, defaultDB, registry, provider, assertActive) {
|
|
300
364
|
const storeDB = store.unprefixedTables == null ? defaultDB : kysely.withPlugin(this.#getStorePlugin(store));
|
|
301
|
-
|
|
302
|
-
|
|
365
|
+
const base = {
|
|
366
|
+
compareWrite: async (table, row)=>{
|
|
367
|
+
assertActive?.();
|
|
368
|
+
return await compareWrite(storeDB, table, row, store.sync?.[table]);
|
|
369
|
+
},
|
|
370
|
+
createLoader: (params)=>{
|
|
371
|
+
assertActive?.();
|
|
372
|
+
return registry.createLoader(params);
|
|
373
|
+
},
|
|
374
|
+
provider,
|
|
375
|
+
root: this,
|
|
376
|
+
onClose: (fn)=>{
|
|
377
|
+
this.#assertOpen();
|
|
378
|
+
this.#closeHooks.push(fn);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
const ext = this.#context?.({
|
|
382
|
+
provider,
|
|
383
|
+
root: this
|
|
303
384
|
});
|
|
385
|
+
const context = Object.defineProperties(base, Object.getOwnPropertyDescriptors(ext ?? {}));
|
|
386
|
+
return store.createAPI(storeDB, this.#adapter, context);
|
|
304
387
|
}
|
|
305
388
|
#getStorePlugin(store) {
|
|
306
389
|
if (store.unprefixedTables == null) return this.#defaultPlugin;
|
|
@@ -363,6 +446,15 @@ export class HozonDB {
|
|
|
363
446
|
if (Object.keys(migrations).length === 0) {
|
|
364
447
|
return;
|
|
365
448
|
}
|
|
449
|
+
// Kysely's migrator opens a transaction even when nothing is pending, which fails while
|
|
450
|
+
// another database instance holds the same single SQLite connection in a transaction.
|
|
451
|
+
const applied = await readAppliedMigrations(this.#db, this.#tablePrefix, name);
|
|
452
|
+
if (applied != null && Object.keys(migrations).every((key)=>applied.has(key))) {
|
|
453
|
+
this.#logger.debug('store {name} migrations already applied', {
|
|
454
|
+
name
|
|
455
|
+
});
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
366
458
|
const migrator = new Migrator({
|
|
367
459
|
db: this.#db,
|
|
368
460
|
provider: {
|
package/lib/errors.d.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export declare class SavepointReleasedError extends Error {
|
|
2
|
+
constructor();
|
|
3
|
+
}
|
|
4
|
+
export declare class KeyLockScopeError extends Error {
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
1
7
|
export declare class SavepointOverlapError extends Error {
|
|
2
8
|
constructor();
|
|
3
9
|
}
|
|
@@ -10,6 +16,9 @@ export declare class InvalidTablePrefixError extends Error {
|
|
|
10
16
|
export declare class HozonDBClosedError extends Error {
|
|
11
17
|
constructor();
|
|
12
18
|
}
|
|
19
|
+
export declare class StoreAlreadyRegisteredError extends Error {
|
|
20
|
+
constructor(store: string);
|
|
21
|
+
}
|
|
13
22
|
export declare class BatchLimitError extends Error {
|
|
14
23
|
constructor(size: number, limit: number);
|
|
15
24
|
}
|
package/lib/errors.js
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
export class SavepointReleasedError extends Error {
|
|
2
|
+
constructor(){
|
|
3
|
+
super('The savepoint has already been released or rolled back');
|
|
4
|
+
this.name = 'SavepointReleasedError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class KeyLockScopeError extends Error {
|
|
8
|
+
constructor(){
|
|
9
|
+
super('The key is reserved by an unsettled savepoint outside this provider scope');
|
|
10
|
+
this.name = 'KeyLockScopeError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
1
13
|
export class SavepointOverlapError extends Error {
|
|
2
14
|
constructor(){
|
|
3
15
|
super('A savepoint is already open in this transaction scope');
|
|
@@ -22,6 +34,12 @@ export class HozonDBClosedError extends Error {
|
|
|
22
34
|
this.name = 'HozonDBClosedError';
|
|
23
35
|
}
|
|
24
36
|
}
|
|
37
|
+
export class StoreAlreadyRegisteredError extends Error {
|
|
38
|
+
constructor(store){
|
|
39
|
+
super(`Store "${store}" is already registered with a different definition`);
|
|
40
|
+
this.name = 'StoreAlreadyRegisteredError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
25
43
|
export class BatchLimitError extends Error {
|
|
26
44
|
constructor(size, limit){
|
|
27
45
|
super(`Batch of ${size} keys exceeds limit ${limit}`);
|
package/lib/index.d.ts
CHANGED
|
@@ -3,9 +3,10 @@ export { Kysely, sql } from 'kysely';
|
|
|
3
3
|
export type { Migration } from 'kysely/migration';
|
|
4
4
|
export { assertBatchSize, type BatchReadOptions, type IterateBatchesOptions, iterateBatches, } from './batch.js';
|
|
5
5
|
export { type BatchLoader, type BatchLoaderParams, createBatchLoader, } from './batch-loader.js';
|
|
6
|
-
export type { HozonDBParams, MigrationContext, StoreContext, StoreDefinition, StoreProvider, } from './db.js';
|
|
6
|
+
export type { BaseStoreContext, ContextFactory, HozonDBParams, MigrationContext, StoreContext, StoreDefinition, StoreProvider, } from './db.js';
|
|
7
7
|
export { HozonDB } from './db.js';
|
|
8
|
-
export { BatchLimitError, HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SchemaVersionError, } from './errors.js';
|
|
9
|
-
export { chunk, withKeepSet } from './keep-set.js';
|
|
8
|
+
export { BatchLimitError, HozonDBClosedError, InvalidTablePrefixError, KeyLockScopeError, SavepointOverlapError, SavepointReleasedError, SchemaVersionError, StoreAlreadyRegisteredError, } from './errors.js';
|
|
9
|
+
export { chunk, IN_LIST_CHUNK, withKeepSet } from './keep-set.js';
|
|
10
10
|
export { withStoreTransaction } from './store-transaction.js';
|
|
11
|
+
export { type CompareDefinition, compareText, withKeyLock, withKeyLocks } from './sync.js';
|
|
11
12
|
export { type TablePrefixOptions, TablePrefixPlugin } from './table-prefix.js';
|
package/lib/index.js
CHANGED
|
@@ -2,7 +2,8 @@ export { Kysely, sql } from 'kysely';
|
|
|
2
2
|
export { assertBatchSize, iterateBatches } from './batch.js';
|
|
3
3
|
export { createBatchLoader } from './batch-loader.js';
|
|
4
4
|
export { HozonDB } from './db.js';
|
|
5
|
-
export { BatchLimitError, HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SchemaVersionError } from './errors.js';
|
|
6
|
-
export { chunk, withKeepSet } from './keep-set.js';
|
|
5
|
+
export { BatchLimitError, HozonDBClosedError, InvalidTablePrefixError, KeyLockScopeError, SavepointOverlapError, SavepointReleasedError, SchemaVersionError, StoreAlreadyRegisteredError } from './errors.js';
|
|
6
|
+
export { chunk, IN_LIST_CHUNK, withKeepSet } from './keep-set.js';
|
|
7
7
|
export { withStoreTransaction } from './store-transaction.js';
|
|
8
|
+
export { compareText, withKeyLock, withKeyLocks } from './sync.js';
|
|
8
9
|
export { TablePrefixPlugin } from './table-prefix.js';
|
package/lib/keep-set.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Kysely, SelectQueryBuilder } from 'kysely';
|
|
2
|
-
export declare
|
|
2
|
+
export declare const IN_LIST_CHUNK = 500;
|
|
3
|
+
export declare function chunk<T>(items: Iterable<T>, size?: number, options?: {
|
|
4
|
+
unique?: boolean;
|
|
5
|
+
}): Array<Array<T>>;
|
|
3
6
|
type KeepTable = {
|
|
4
7
|
keep: {
|
|
5
8
|
trace_id: string;
|
package/lib/keep-set.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { sql } from 'kysely';
|
|
2
|
-
export
|
|
2
|
+
export const IN_LIST_CHUNK = 500;
|
|
3
|
+
export function chunk(items, size = IN_LIST_CHUNK, options) {
|
|
3
4
|
if (!Number.isInteger(size) || size <= 0) throw new RangeError('Chunk size must be a positive integer');
|
|
4
5
|
const chunks = [];
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
const values = options?.unique ? [
|
|
7
|
+
...new Set(items)
|
|
8
|
+
] : [
|
|
9
|
+
...items
|
|
10
|
+
];
|
|
11
|
+
for(let offset = 0; offset < values.length; offset += size){
|
|
12
|
+
chunks.push(values.slice(offset, offset + size));
|
|
7
13
|
}
|
|
8
14
|
return chunks;
|
|
9
15
|
}
|
package/lib/preflight.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
import type { Kysely } from 'kysely';
|
|
2
|
+
/** Names of the migrations recorded for a store, or `null` when its migration table is absent. */
|
|
3
|
+
export declare function readAppliedMigrations<DB>(db: Kysely<DB>, prefix: string, name: string): Promise<Set<string> | null>;
|
|
2
4
|
export declare function checkStore<DB>(db: Kysely<DB>, prefix: string, name: string, keys: Array<string>): Promise<void>;
|
package/lib/preflight.js
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { sql } from 'kysely';
|
|
2
2
|
import { SchemaVersionError } from './errors.js';
|
|
3
|
-
export async function
|
|
4
|
-
let rows;
|
|
3
|
+
/** Names of the migrations recorded for a store, or `null` when its migration table is absent. */ export async function readAppliedMigrations(db, prefix, name) {
|
|
5
4
|
try {
|
|
6
5
|
const result = await sql`SELECT name FROM ${sql.table(`${prefix}_${name}_migration`)}`.execute(db);
|
|
7
|
-
|
|
6
|
+
return new Set(result.rows.map((row)=>row.name));
|
|
8
7
|
} catch (error) {
|
|
9
8
|
// Only a missing table is a fresh schema. Connection and permission errors must surface.
|
|
10
9
|
if (error instanceof Error && ('code' in error && error.code === '42P01' || /no such table:/i.test(error.message))) {
|
|
11
|
-
return;
|
|
10
|
+
return null;
|
|
12
11
|
}
|
|
13
12
|
throw error;
|
|
14
13
|
}
|
|
14
|
+
}
|
|
15
|
+
export async function checkStore(db, prefix, name, keys) {
|
|
16
|
+
const applied = await readAppliedMigrations(db, prefix, name);
|
|
17
|
+
if (applied == null) return;
|
|
15
18
|
const known = new Set(keys);
|
|
16
|
-
const unknown =
|
|
19
|
+
const unknown = [
|
|
20
|
+
...applied
|
|
21
|
+
].filter((key)=>!known.has(key)).sort();
|
|
17
22
|
if (unknown.length > 0) throw new SchemaVersionError(name, unknown);
|
|
18
23
|
}
|
package/lib/sync.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Adapter } from '@hozon/adapter';
|
|
2
|
+
import type { ColumnDefinitionBuilder, Kysely } from 'kysely';
|
|
3
|
+
import type { MigrationContext, StoreProvider } from './db.js';
|
|
4
|
+
export type CompareDefinition<Row> = {
|
|
5
|
+
key: Array<keyof Row & string>;
|
|
6
|
+
compare: Array<keyof Row & string>;
|
|
7
|
+
inclusive?: boolean;
|
|
8
|
+
nullsOldest?: boolean;
|
|
9
|
+
skipUnchanged?: boolean;
|
|
10
|
+
cursor?: never;
|
|
11
|
+
tombstones?: never;
|
|
12
|
+
};
|
|
13
|
+
type LockContext = {
|
|
14
|
+
assertActive: () => void;
|
|
15
|
+
derive: () => StoreProvider;
|
|
16
|
+
db: Kysely<Record<string, unknown>>;
|
|
17
|
+
kind: Adapter['kind'];
|
|
18
|
+
tablePrefix: string;
|
|
19
|
+
tableName: (table: string) => string;
|
|
20
|
+
};
|
|
21
|
+
export declare function settleSyncProvider(provider: StoreProvider): void;
|
|
22
|
+
export declare function bindSyncProvider(provider: StoreProvider, context: LockContext, parent?: StoreProvider, savepoint?: boolean): void;
|
|
23
|
+
export declare function withKeyLock<R>(provider: StoreProvider, table: string, key: ReadonlyArray<string | number>, fn: (tx: StoreProvider) => Promise<R>): Promise<R>;
|
|
24
|
+
export declare function withKeyLocks<R>(provider: StoreProvider, table: string, keys: ReadonlyArray<ReadonlyArray<string | number>>, fn: (tx: StoreProvider) => Promise<R>): Promise<R>;
|
|
25
|
+
export declare function compareText(ctx: MigrationContext): (column: ColumnDefinitionBuilder) => ColumnDefinitionBuilder;
|
|
26
|
+
export declare function compareWrite(db: Kysely<Record<string, unknown>>, table: string, row: Record<string, unknown>, definition?: CompareDefinition<Record<string, unknown>>): Promise<{
|
|
27
|
+
changed: boolean;
|
|
28
|
+
}>;
|
|
29
|
+
export {};
|
package/lib/sync.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import { KeyLockScopeError } from './errors.js';
|
|
3
|
+
const providerScopes = new WeakMap();
|
|
4
|
+
const savepointScopes = new WeakMap();
|
|
5
|
+
export function settleSyncProvider(provider) {
|
|
6
|
+
const scope = providerScopes.get(provider);
|
|
7
|
+
if (scope) savepointScopes.get(provider.transactionRoot ?? provider)?.delete(scope);
|
|
8
|
+
}
|
|
9
|
+
const lockContexts = new WeakMap();
|
|
10
|
+
const ownership = new WeakMap();
|
|
11
|
+
const keyLocks = new WeakMap();
|
|
12
|
+
export function bindSyncProvider(provider, context, parent, savepoint = false) {
|
|
13
|
+
lockContexts.set(provider, context);
|
|
14
|
+
ownership.set(provider, new Map(parent == null ? [] : ownership.get(parent)));
|
|
15
|
+
const parentScope = parent == null ? undefined : providerScopes.get(parent);
|
|
16
|
+
if (savepoint) {
|
|
17
|
+
const scope = {
|
|
18
|
+
parent: parentScope,
|
|
19
|
+
keys: new Set()
|
|
20
|
+
};
|
|
21
|
+
providerScopes.set(provider, scope);
|
|
22
|
+
const root = provider.transactionRoot ?? provider;
|
|
23
|
+
let scopes = savepointScopes.get(root);
|
|
24
|
+
if (!scopes) {
|
|
25
|
+
scopes = new Set();
|
|
26
|
+
savepointScopes.set(root, scopes);
|
|
27
|
+
}
|
|
28
|
+
scopes.add(scope);
|
|
29
|
+
} else if (parentScope) {
|
|
30
|
+
providerScopes.set(provider, parentScope);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function withQueue(root, key, fn) {
|
|
34
|
+
let locks = keyLocks.get(root);
|
|
35
|
+
if (locks == null) {
|
|
36
|
+
locks = new Map();
|
|
37
|
+
keyLocks.set(root, locks);
|
|
38
|
+
}
|
|
39
|
+
const previous = locks.get(key) ?? Promise.resolve();
|
|
40
|
+
let release;
|
|
41
|
+
const current = new Promise((resolve)=>{
|
|
42
|
+
release = resolve;
|
|
43
|
+
});
|
|
44
|
+
const tail = previous.then(()=>current);
|
|
45
|
+
locks.set(key, tail);
|
|
46
|
+
await previous;
|
|
47
|
+
try {
|
|
48
|
+
return await fn();
|
|
49
|
+
} finally{
|
|
50
|
+
release();
|
|
51
|
+
if (locks.get(key) === tail) locks.delete(key);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function withKeyLock(provider, table, key, fn) {
|
|
55
|
+
return await withKeyLocks(provider, table, [
|
|
56
|
+
key
|
|
57
|
+
], fn);
|
|
58
|
+
}
|
|
59
|
+
export async function withKeyLocks(provider, table, keys, fn) {
|
|
60
|
+
if (!provider.isTransactionBound) {
|
|
61
|
+
return await provider.withTransaction((tx)=>withKeyLocks(tx, table, keys, fn));
|
|
62
|
+
}
|
|
63
|
+
const root = provider.transactionRoot ?? provider;
|
|
64
|
+
const context = lockContexts.get(provider);
|
|
65
|
+
if (context == null) throw new Error('withKeyLock requires a HozonDB provider');
|
|
66
|
+
context.assertActive();
|
|
67
|
+
const identities = [
|
|
68
|
+
...new Set(keys.map((key)=>{
|
|
69
|
+
if (key.some((part)=>typeof part === 'number' && !Number.isFinite(part))) {
|
|
70
|
+
throw new Error('Lock keys require finite numbers');
|
|
71
|
+
}
|
|
72
|
+
return JSON.stringify([
|
|
73
|
+
context.tablePrefix,
|
|
74
|
+
table,
|
|
75
|
+
key
|
|
76
|
+
]);
|
|
77
|
+
}))
|
|
78
|
+
];
|
|
79
|
+
let lockIDs;
|
|
80
|
+
if (context.kind === 'postgres') {
|
|
81
|
+
const ids = [];
|
|
82
|
+
for (const identity of identities){
|
|
83
|
+
const result = await sql`SELECT (('x' || substr(md5(${identity}::text), 1, 16))::bit(64)::bigint)::text AS id`.execute(context.db);
|
|
84
|
+
for (const row of result.rows)ids.push(BigInt(row.id));
|
|
85
|
+
}
|
|
86
|
+
// Order and deduplicate the actual IDs: collisions only add contention.
|
|
87
|
+
lockIDs = [
|
|
88
|
+
...new Set(ids)
|
|
89
|
+
].sort((a, b)=>a < b ? -1 : a > b ? 1 : 0).map(String);
|
|
90
|
+
} else {
|
|
91
|
+
lockIDs = identities.sort();
|
|
92
|
+
}
|
|
93
|
+
const scope = providerScopes.get(provider);
|
|
94
|
+
const ancestors = new Set();
|
|
95
|
+
for(let current = scope; current; current = current.parent)ancestors.add(current);
|
|
96
|
+
const checkScope = (id)=>{
|
|
97
|
+
for (const reservation of savepointScopes.get(root) ?? []){
|
|
98
|
+
if (reservation.keys.has(id) && !ancestors.has(reservation)) throw new KeyLockScopeError();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
for (const id of lockIDs)checkScope(id);
|
|
102
|
+
const held = ownership.get(provider);
|
|
103
|
+
const acquired = {
|
|
104
|
+
active: true
|
|
105
|
+
};
|
|
106
|
+
const locked = context.derive();
|
|
107
|
+
// biome-ignore lint/style/noNonNullAssertion: derive binds ownership before returning.
|
|
108
|
+
const owned = ownership.get(locked);
|
|
109
|
+
const acquire = async (index)=>{
|
|
110
|
+
const id = lockIDs[index];
|
|
111
|
+
if (id === undefined) {
|
|
112
|
+
if (context.kind === 'sqlite' && lockIDs.length > 0) {
|
|
113
|
+
// Even a zero-row DELETE reserves SQLite's writer before the callback reads.
|
|
114
|
+
await sql`DELETE FROM ${sql.table(context.tableName(table))} WHERE 1 = 0`.execute(context.db);
|
|
115
|
+
}
|
|
116
|
+
for (const id of lockIDs){
|
|
117
|
+
checkScope(id);
|
|
118
|
+
for (const ancestor of ancestors)ancestor.keys.add(id);
|
|
119
|
+
}
|
|
120
|
+
context.assertActive();
|
|
121
|
+
return await fn(locked);
|
|
122
|
+
}
|
|
123
|
+
checkScope(id);
|
|
124
|
+
if (held?.get(id)?.active) return await acquire(index + 1);
|
|
125
|
+
return await withQueue(root, id, async ()=>{
|
|
126
|
+
checkScope(id);
|
|
127
|
+
if (context.kind === 'postgres') {
|
|
128
|
+
await sql`SELECT pg_advisory_xact_lock(${id}::bigint)`.execute(context.db);
|
|
129
|
+
}
|
|
130
|
+
for (const ancestor of ancestors)ancestor.keys.add(id);
|
|
131
|
+
owned.set(id, acquired);
|
|
132
|
+
return await acquire(index + 1);
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
try {
|
|
136
|
+
return await acquire(0);
|
|
137
|
+
} finally{
|
|
138
|
+
acquired.active = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function compareText(ctx) {
|
|
142
|
+
return (column)=>ctx.kind === 'postgres' ? column.modifyEnd(sql`collate "C"`) : column;
|
|
143
|
+
}
|
|
144
|
+
export async function compareWrite(db, table, row, definition) {
|
|
145
|
+
if (definition == null) throw new Error(`Table "${table}" has no sync declaration`);
|
|
146
|
+
if (definition.key.length === 0 || definition.compare.length === 0) {
|
|
147
|
+
throw new Error('Sync declarations require key and compare columns');
|
|
148
|
+
}
|
|
149
|
+
const columns = Object.keys(row);
|
|
150
|
+
const stored = (column)=>sql.ref(`${table}.${column}`);
|
|
151
|
+
const incoming = (column)=>sql.ref(`excluded.${column}`);
|
|
152
|
+
const tuple = (names, ref)=>sql`(${sql.join(names.map(ref))})`;
|
|
153
|
+
const { compare } = definition;
|
|
154
|
+
let predicate;
|
|
155
|
+
if (definition.nullsOldest) {
|
|
156
|
+
const alternatives = compare.map((column, index)=>{
|
|
157
|
+
const equal = compare.slice(0, index).map((previous)=>sql`${incoming(previous)} IS NOT DISTINCT FROM ${stored(previous)}`);
|
|
158
|
+
const greater = sql`((${stored(column)} IS NULL AND ${incoming(column)} IS NOT NULL) OR ${incoming(column)} > ${stored(column)})`;
|
|
159
|
+
return sql`(${sql.join([
|
|
160
|
+
...equal,
|
|
161
|
+
greater
|
|
162
|
+
], sql` AND `)})`;
|
|
163
|
+
});
|
|
164
|
+
if (definition.inclusive) {
|
|
165
|
+
alternatives.push(sql`(${sql.join(compare.map((column)=>sql`${incoming(column)} IS NOT DISTINCT FROM ${stored(column)}`), sql` AND `)})`);
|
|
166
|
+
}
|
|
167
|
+
predicate = sql`(${sql.join(alternatives, sql` OR `)})`;
|
|
168
|
+
} else {
|
|
169
|
+
predicate = sql`${tuple(compare, incoming)} ${definition.inclusive ? sql`>=` : sql`>`} ${tuple(compare, stored)}`;
|
|
170
|
+
}
|
|
171
|
+
if (definition.skipUnchanged) {
|
|
172
|
+
predicate = sql`(${predicate}) AND ${tuple(columns, stored)} IS DISTINCT FROM ${tuple(columns, incoming)}`;
|
|
173
|
+
}
|
|
174
|
+
const query = db;
|
|
175
|
+
const updates = Object.fromEntries(columns.map((column)=>[
|
|
176
|
+
column,
|
|
177
|
+
incoming(column)
|
|
178
|
+
]));
|
|
179
|
+
const statement = query.insertInto(table).values(row).onConflict((conflict)=>conflict.columns(definition.key).doUpdateSet(updates).where(predicate)).returning(definition.key);
|
|
180
|
+
const compiled = statement.compile();
|
|
181
|
+
if (compiled.parameters.length > 500) {
|
|
182
|
+
throw new Error('compareWrite cannot bind more than 500 parameters');
|
|
183
|
+
}
|
|
184
|
+
const result = await db.executeQuery(compiled);
|
|
185
|
+
return {
|
|
186
|
+
changed: result.rows.length > 0
|
|
187
|
+
};
|
|
188
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hozon/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Database registry, migrations and transactions for Hozon",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,13 +17,13 @@
|
|
|
17
17
|
"lib/*"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@hozon/adapter": "^0.1
|
|
20
|
+
"@hozon/adapter": "^0.2.1",
|
|
21
21
|
"@sozai/async": "^0.4.1",
|
|
22
22
|
"@sozai/log": "^0.4.0",
|
|
23
23
|
"kysely": "^0.29.7"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@hozon/node-sqlite": "^0.
|
|
26
|
+
"@hozon/node-sqlite": "^0.2.0",
|
|
27
27
|
"@types/node": "^26.6.5",
|
|
28
28
|
"del-cli": "^8.0.1",
|
|
29
29
|
"vitest": "5.0.5"
|