@hozon/db 0.2.3 → 0.4.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/batch-loader.d.ts +19 -0
- package/lib/batch-loader.js +84 -0
- package/lib/batch.d.ts +17 -0
- package/lib/batch.js +27 -0
- package/lib/db.d.ts +41 -7
- package/lib/db.js +167 -16
- package/lib/errors.d.ts +9 -0
- package/lib/errors.js +18 -0
- package/lib/index.d.ts +6 -3
- package/lib/index.js +5 -2
- package/lib/keep-set.d.ts +4 -1
- package/lib/keep-set.js +9 -3
- package/lib/loader-registry.d.ts +11 -0
- package/lib/loader-registry.js +26 -0
- package/lib/sync.d.ts +29 -0
- package/lib/sync.js +188 -0
- package/package.json +8 -7
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type BatchLoaderParams<TKey, TValue> = {
|
|
2
|
+
load: (keys: Array<TKey>) => Promise<Map<TKey, TValue | null>>;
|
|
3
|
+
maxBatchSize: number;
|
|
4
|
+
};
|
|
5
|
+
export type BatchLoader<TKey, TValue> = {
|
|
6
|
+
load(key: TKey): Promise<TValue | null>;
|
|
7
|
+
clear(key: TKey): void;
|
|
8
|
+
clearAll(): void;
|
|
9
|
+
dispatch(): void;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Coalesces same-tick `load` calls into batched `params.load` calls of at most `maxBatchSize`
|
|
13
|
+
* keys. `remember` is read at settle time so a scope can switch the loader to batch-only.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createBatchLoader<TKey, TValue>(params: BatchLoaderParams<TKey, TValue> & {
|
|
16
|
+
remember: () => boolean;
|
|
17
|
+
}): BatchLoader<TKey, TValue>;
|
|
18
|
+
/** Test probe: number of per-key generation entries a loader currently tracks. */
|
|
19
|
+
export declare function trackedGenerationCount(loader: object): number;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { defer } from '@sozai/async';
|
|
2
|
+
import { chunk } from './keep-set.js';
|
|
3
|
+
// Runs after the microtask queue drains, so loads chained behind a few awaits still coalesce.
|
|
4
|
+
function scheduleAfterMicrotasks(fn) {
|
|
5
|
+
if (typeof setImmediate === 'function') setImmediate(fn);
|
|
6
|
+
else setTimeout(fn, 0);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Coalesces same-tick `load` calls into batched `params.load` calls of at most `maxBatchSize`
|
|
10
|
+
* keys. `remember` is read at settle time so a scope can switch the loader to batch-only.
|
|
11
|
+
*/ export function createBatchLoader(params) {
|
|
12
|
+
const { load, maxBatchSize, remember } = params;
|
|
13
|
+
let pending = new Map();
|
|
14
|
+
let scheduled = false;
|
|
15
|
+
const remembered = new Map();
|
|
16
|
+
// Both counters only grow, so their sum changes whenever either is bumped.
|
|
17
|
+
const keyGenerations = new Map();
|
|
18
|
+
let globalGeneration = 0;
|
|
19
|
+
// Per-key bumps only matter to in-flight chunks; dropping them when idle bounds the map.
|
|
20
|
+
let inFlight = 0;
|
|
21
|
+
const generationOf = (key)=>globalGeneration + (keyGenerations.get(key) ?? 0);
|
|
22
|
+
async function dispatchChunk(entries) {
|
|
23
|
+
const generations = entries.map(([key])=>generationOf(key));
|
|
24
|
+
inFlight++;
|
|
25
|
+
try {
|
|
26
|
+
const results = await load(entries.map(([key])=>key));
|
|
27
|
+
const shouldRemember = remember();
|
|
28
|
+
entries.forEach(([key, deferred], index)=>{
|
|
29
|
+
const value = results.get(key) ?? null;
|
|
30
|
+
if (shouldRemember && generationOf(key) === generations[index]) {
|
|
31
|
+
remembered.set(key, value);
|
|
32
|
+
}
|
|
33
|
+
deferred.resolve(value);
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
for (const [, deferred] of entries)deferred.reject(error);
|
|
37
|
+
} finally{
|
|
38
|
+
inFlight--;
|
|
39
|
+
if (inFlight === 0) keyGenerations.clear();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function dispatch() {
|
|
43
|
+
if (pending.size === 0) return;
|
|
44
|
+
const entries = [
|
|
45
|
+
...pending
|
|
46
|
+
];
|
|
47
|
+
pending = new Map();
|
|
48
|
+
for (const part of chunk(entries, maxBatchSize))void dispatchChunk(part);
|
|
49
|
+
}
|
|
50
|
+
const loader = {
|
|
51
|
+
load (key) {
|
|
52
|
+
if (remember() && remembered.has(key)) {
|
|
53
|
+
return Promise.resolve(remembered.get(key));
|
|
54
|
+
}
|
|
55
|
+
const existing = pending.get(key);
|
|
56
|
+
if (existing != null) return existing.promise;
|
|
57
|
+
const deferred = defer();
|
|
58
|
+
pending.set(key, deferred);
|
|
59
|
+
if (!scheduled) {
|
|
60
|
+
scheduled = true;
|
|
61
|
+
scheduleAfterMicrotasks(()=>{
|
|
62
|
+
scheduled = false;
|
|
63
|
+
dispatch();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return deferred.promise;
|
|
67
|
+
},
|
|
68
|
+
clear (key) {
|
|
69
|
+
if (inFlight > 0) keyGenerations.set(key, (keyGenerations.get(key) ?? 0) + 1);
|
|
70
|
+
remembered.delete(key);
|
|
71
|
+
},
|
|
72
|
+
clearAll () {
|
|
73
|
+
globalGeneration++;
|
|
74
|
+
remembered.clear();
|
|
75
|
+
},
|
|
76
|
+
dispatch
|
|
77
|
+
};
|
|
78
|
+
generationProbes.set(loader, ()=>keyGenerations.size);
|
|
79
|
+
return loader;
|
|
80
|
+
}
|
|
81
|
+
const generationProbes = new WeakMap();
|
|
82
|
+
/** Test probe: number of per-key generation entries a loader currently tracks. */ export function trackedGenerationCount(loader) {
|
|
83
|
+
return generationProbes.get(loader)?.() ?? 0;
|
|
84
|
+
}
|
package/lib/batch.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type BatchReadOptions = {
|
|
2
|
+
batchLimit?: number;
|
|
3
|
+
};
|
|
4
|
+
export type IterateBatchesOptions = {
|
|
5
|
+
batchSize: number;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
};
|
|
8
|
+
/** Throws if `size` exceeds `batchLimit ?? max`, or if `batchLimit` is invalid or above `max`. */
|
|
9
|
+
export declare function assertBatchSize(size: number, max: number, batchLimit?: number): void;
|
|
10
|
+
/**
|
|
11
|
+
* Dedupes `keys`, loads them in sequential chunks of `batchSize` and yields each chunk with
|
|
12
|
+
* its results. Checks `signal` before each chunk.
|
|
13
|
+
*/
|
|
14
|
+
export declare function iterateBatches<TKey, TValue>(keys: Iterable<TKey>, load: (chunk: Array<TKey>) => Promise<Map<TKey, TValue | null>>, options: IterateBatchesOptions): AsyncGenerator<{
|
|
15
|
+
keys: Array<TKey>;
|
|
16
|
+
results: Map<TKey, TValue | null>;
|
|
17
|
+
}>;
|
package/lib/batch.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { BatchLimitError } from './errors.js';
|
|
2
|
+
import { chunk } from './keep-set.js';
|
|
3
|
+
/** Throws if `size` exceeds `batchLimit ?? max`, or if `batchLimit` is invalid or above `max`. */ export function assertBatchSize(size, max, batchLimit) {
|
|
4
|
+
if (batchLimit != null && (!Number.isInteger(batchLimit) || batchLimit <= 0 || batchLimit > max)) {
|
|
5
|
+
throw new RangeError(`batchLimit must be a positive integer no greater than ${max}`);
|
|
6
|
+
}
|
|
7
|
+
const limit = batchLimit ?? max;
|
|
8
|
+
if (size > limit) throw new BatchLimitError(size, limit);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Dedupes `keys`, loads them in sequential chunks of `batchSize` and yields each chunk with
|
|
12
|
+
* its results. Checks `signal` before each chunk.
|
|
13
|
+
*/ export async function* iterateBatches(keys, load, options) {
|
|
14
|
+
const { batchSize, signal } = options;
|
|
15
|
+
if (!Number.isInteger(batchSize) || batchSize <= 0) {
|
|
16
|
+
throw new RangeError('batchSize must be a positive integer');
|
|
17
|
+
}
|
|
18
|
+
for (const chunkKeys of chunk([
|
|
19
|
+
...new Set(keys)
|
|
20
|
+
], batchSize)){
|
|
21
|
+
signal?.throwIfAborted();
|
|
22
|
+
yield {
|
|
23
|
+
keys: chunkKeys,
|
|
24
|
+
results: await load(chunkKeys)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
package/lib/db.d.ts
CHANGED
|
@@ -2,13 +2,32 @@ import type { Adapter, ColumnTypes, Functions } from '@hozon/adapter';
|
|
|
2
2
|
import { type Logger } from '@sozai/log';
|
|
3
3
|
import { Kysely } from 'kysely';
|
|
4
4
|
import { type Migration } from 'kysely/migration';
|
|
5
|
+
import type { BatchLoader, BatchLoaderParams } from './batch-loader.js';
|
|
6
|
+
import { type CompareDefinition } from './sync.js';
|
|
5
7
|
export type MigrationContext = {
|
|
6
8
|
tablePrefix: string;
|
|
7
9
|
kind: Adapter['kind'];
|
|
8
10
|
types: ColumnTypes;
|
|
9
11
|
functions: Functions;
|
|
10
12
|
};
|
|
11
|
-
export type
|
|
13
|
+
export type BaseStoreContext = {
|
|
14
|
+
compareWrite<T extends string>(table: T, row: Record<string, unknown>): Promise<{
|
|
15
|
+
changed: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
createLoader<TKey, TValue>(params: BatchLoaderParams<TKey, TValue>): BatchLoader<TKey, TValue>;
|
|
18
|
+
provider: StoreProvider;
|
|
19
|
+
root: StoreProvider;
|
|
20
|
+
onClose(fn: () => Promise<void> | void): void;
|
|
21
|
+
};
|
|
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 = {}> = {
|
|
12
31
|
name: string;
|
|
13
32
|
migrations: Record<string, Migration> | ((ctx: MigrationContext) => Record<string, Migration>);
|
|
14
33
|
dependsOn?: Array<string>;
|
|
@@ -18,9 +37,15 @@ export type StoreDefinition<Tables, API> = {
|
|
|
18
37
|
* tables are not namespaced: the store must keep them collision-free.
|
|
19
38
|
*/
|
|
20
39
|
unprefixedTables?: (name: string) => boolean;
|
|
21
|
-
|
|
40
|
+
sync?: {
|
|
41
|
+
[T in keyof Tables & string]?: CompareDefinition<Tables[T]>;
|
|
42
|
+
};
|
|
43
|
+
createAPI: (db: Kysely<Tables>, adapter: Adapter, context: StoreContext<Ext>) => API;
|
|
22
44
|
};
|
|
23
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;
|
|
24
49
|
getStore<S extends keyof Stores & string>(name: S): Promise<Stores[S]>;
|
|
25
50
|
/**
|
|
26
51
|
* Returns whether a store definition is registered, synchronously and
|
|
@@ -53,23 +78,31 @@ export type StoreProvider<Stores extends Record<string, unknown> = Record<string
|
|
|
53
78
|
* transaction or explicit savepoint.
|
|
54
79
|
*/
|
|
55
80
|
withTransaction<NestedStores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
|
|
81
|
+
withScope?<NestedStores extends Record<string, unknown>, R>(fn: (scope: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
|
|
56
82
|
/** Roll back only this unit of work when called inside a transaction. */
|
|
57
83
|
withSavepoint?<NestedStores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
|
|
58
84
|
};
|
|
59
|
-
export type HozonDBParams = {
|
|
85
|
+
export type HozonDBParams<Ext extends ContextExtension = {}> = {
|
|
60
86
|
adapter: Adapter;
|
|
61
87
|
logger?: Logger;
|
|
62
88
|
tablePrefix?: string;
|
|
63
|
-
}
|
|
64
|
-
|
|
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 {
|
|
65
95
|
#private;
|
|
66
|
-
constructor(params: HozonDBParams);
|
|
96
|
+
constructor(params: HozonDBParams<Ext>);
|
|
67
97
|
get adapter(): Adapter;
|
|
68
|
-
|
|
98
|
+
get isTransactionBound(): boolean;
|
|
99
|
+
get transactionRoot(): StoreProvider | undefined;
|
|
100
|
+
register<Tables, API>(store: StoreDefinition<Tables, API, Ext>): void;
|
|
69
101
|
getStore<T>(name: string): Promise<T>;
|
|
70
102
|
hasStore(name: string): boolean;
|
|
71
103
|
onCommit(fn: () => void): void;
|
|
72
104
|
onRollback(_fn: () => void): void;
|
|
105
|
+
withScope<Stores extends Record<string, unknown>, R>(fn: (scope: StoreProvider<Stores>) => Promise<R>): Promise<R>;
|
|
73
106
|
/**
|
|
74
107
|
* Runs `fn` inside a DB transaction and provides a scoped `StoreProvider`.
|
|
75
108
|
* Concurrent calls on the same `HozonDB` instance are supported — the
|
|
@@ -83,3 +116,4 @@ export declare class HozonDB implements StoreProvider {
|
|
|
83
116
|
migrate(): Promise<void>;
|
|
84
117
|
close(): Promise<void>;
|
|
85
118
|
}
|
|
119
|
+
export {};
|
package/lib/db.js
CHANGED
|
@@ -2,10 +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 } from './errors.js';
|
|
6
|
+
import { LoaderRegistry } from './loader-registry.js';
|
|
6
7
|
import { checkStore } from './preflight.js';
|
|
8
|
+
import { bindSyncProvider, compareWrite, settleSyncProvider } from './sync.js';
|
|
7
9
|
import { TablePrefixPlugin } from './table-prefix.js';
|
|
10
|
+
// biome-ignore lint/complexity/noBannedTypes: The default adds no context fields.
|
|
8
11
|
export class HozonDB {
|
|
12
|
+
#context;
|
|
13
|
+
#closeHooks = [];
|
|
9
14
|
#tablePrefix;
|
|
10
15
|
#defaultPlugin;
|
|
11
16
|
#closed = false;
|
|
@@ -19,9 +24,13 @@ export class HozonDB {
|
|
|
19
24
|
#storePlugins = new Map();
|
|
20
25
|
#logger;
|
|
21
26
|
#migrated = new Map();
|
|
27
|
+
#loaders = new LoaderRegistry({
|
|
28
|
+
remember: false
|
|
29
|
+
});
|
|
22
30
|
#storeAPIs = new Map();
|
|
23
31
|
#stores = new Map();
|
|
24
32
|
constructor(params){
|
|
33
|
+
this.#context = params.context;
|
|
25
34
|
this.#tablePrefix = params.tablePrefix ?? 'hozon';
|
|
26
35
|
if (!/^[a-z][a-z0-9_]{0,30}$/.test(this.#tablePrefix)) {
|
|
27
36
|
throw new InvalidTablePrefixError(this.#tablePrefix);
|
|
@@ -41,6 +50,12 @@ export class HozonDB {
|
|
|
41
50
|
get adapter() {
|
|
42
51
|
return this.#adapter;
|
|
43
52
|
}
|
|
53
|
+
get isTransactionBound() {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
get transactionRoot() {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
44
59
|
register(store) {
|
|
45
60
|
const existing = this.#stores.get(store.name);
|
|
46
61
|
if (existing != null) {
|
|
@@ -63,8 +78,7 @@ export class HozonDB {
|
|
|
63
78
|
if (store == null) {
|
|
64
79
|
throw new Error(`Store "${name}" is not registered`);
|
|
65
80
|
}
|
|
66
|
-
|
|
67
|
-
this.#storeAPIs.set(name, store.createAPI(storeDB, this.#adapter));
|
|
81
|
+
this.#storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, this.#loaders, this));
|
|
68
82
|
}
|
|
69
83
|
return this.#storeAPIs.get(name);
|
|
70
84
|
}
|
|
@@ -76,6 +90,42 @@ export class HozonDB {
|
|
|
76
90
|
}
|
|
77
91
|
// Each standalone op auto-commits, so there is nothing to roll back — no-op.
|
|
78
92
|
onRollback(_fn) {}
|
|
93
|
+
async withScope(fn) {
|
|
94
|
+
this.#assertOpen();
|
|
95
|
+
const registry = new LoaderRegistry({
|
|
96
|
+
remember: true
|
|
97
|
+
});
|
|
98
|
+
const storeAPIs = new Map();
|
|
99
|
+
const provider = {
|
|
100
|
+
isTransactionBound: false,
|
|
101
|
+
getStore: async (name)=>{
|
|
102
|
+
this.#assertOpen();
|
|
103
|
+
await this.#ensurePreflight();
|
|
104
|
+
await this.#ensureMigrated(name);
|
|
105
|
+
this.#assertOpen();
|
|
106
|
+
if (!storeAPIs.has(name)) {
|
|
107
|
+
const store = this.#stores.get(name);
|
|
108
|
+
if (store == null) throw new Error(`Store "${name}" is not registered`);
|
|
109
|
+
storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, registry, provider));
|
|
110
|
+
}
|
|
111
|
+
return storeAPIs.get(name);
|
|
112
|
+
},
|
|
113
|
+
hasStore: (name)=>this.hasStore(name),
|
|
114
|
+
onCommit: (hook)=>hook(),
|
|
115
|
+
onRollback: ()=>{},
|
|
116
|
+
withScope: async (nestedFn)=>{
|
|
117
|
+
return await nestedFn(provider);
|
|
118
|
+
},
|
|
119
|
+
withTransaction: async (nestedFn)=>{
|
|
120
|
+
return await this.#withTransaction(nestedFn, ()=>registry.clearAll());
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
return await fn(provider);
|
|
125
|
+
} finally{
|
|
126
|
+
registry.close();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
79
129
|
/**
|
|
80
130
|
* Runs `fn` inside a DB transaction and provides a scoped `StoreProvider`.
|
|
81
131
|
* Concurrent calls on the same `HozonDB` instance are supported — the
|
|
@@ -85,6 +135,9 @@ export class HozonDB {
|
|
|
85
135
|
* `db.withTransaction` on the same instance inside the callback opens a
|
|
86
136
|
* new independent transaction (no atomic nesting) and is a bug.
|
|
87
137
|
*/ async withTransaction(fn) {
|
|
138
|
+
return await this.#withTransaction(fn);
|
|
139
|
+
}
|
|
140
|
+
async #withTransaction(fn, beforeHooks) {
|
|
88
141
|
// Migrate every registered store before opening the transaction. Stores
|
|
89
142
|
// are internal and known up front, so a broken migration should fail fast
|
|
90
143
|
// at startup rather than leave a runtime partition where one store's
|
|
@@ -93,6 +146,9 @@ export class HozonDB {
|
|
|
93
146
|
this.#assertOpen();
|
|
94
147
|
const commitHooks = [];
|
|
95
148
|
const rollbackHooks = [];
|
|
149
|
+
const registry = new LoaderRegistry({
|
|
150
|
+
remember: true
|
|
151
|
+
});
|
|
96
152
|
let result;
|
|
97
153
|
let savepointID = 0;
|
|
98
154
|
try {
|
|
@@ -100,68 +156,124 @@ export class HozonDB {
|
|
|
100
156
|
result = await this.#db.transaction().execute(async (trx)=>{
|
|
101
157
|
const txKysely = trx;
|
|
102
158
|
const txStoreDB = txKysely.withPlugin(this.#defaultPlugin);
|
|
103
|
-
const
|
|
104
|
-
|
|
159
|
+
const createScopedProvider = (scopedCommitHooks, scopedRollbackHooks, transactionRoot, syncParent, syncSavepoint = false, scopeState = {
|
|
160
|
+
savepointOpen: false,
|
|
161
|
+
settled: false,
|
|
162
|
+
savepointBound: syncSavepoint
|
|
163
|
+
})=>{
|
|
105
164
|
// Savepoints on one connection form a stack: a scope holds at most
|
|
106
165
|
// one open savepoint, and nesting goes through that savepoint's
|
|
107
166
|
// own provider.
|
|
108
|
-
|
|
167
|
+
const assertActive = ()=>{
|
|
168
|
+
if (scopeState.settled) throw new SavepointReleasedError();
|
|
169
|
+
};
|
|
170
|
+
const txStoreAPIs = new Map();
|
|
109
171
|
const provider = {
|
|
172
|
+
isTransactionBound: true,
|
|
173
|
+
get transactionRoot () {
|
|
174
|
+
return transactionRoot ?? provider;
|
|
175
|
+
},
|
|
110
176
|
getStore: async (name)=>{
|
|
177
|
+
assertActive();
|
|
178
|
+
this.#assertOpen();
|
|
111
179
|
if (!txStoreAPIs.has(name)) {
|
|
112
180
|
const store = this.#stores.get(name);
|
|
113
181
|
if (store == null) {
|
|
114
182
|
throw new Error(`Store "${name}" is not registered`);
|
|
115
183
|
}
|
|
116
|
-
|
|
117
|
-
txStoreAPIs.set(name, store.createAPI(storeDB, this.#adapter));
|
|
184
|
+
txStoreAPIs.set(name, this.#createStoreAPI(store, txKysely, txStoreDB, registry, provider, scopeState.savepointBound ? assertActive : undefined));
|
|
118
185
|
}
|
|
119
186
|
return txStoreAPIs.get(name);
|
|
120
187
|
},
|
|
121
|
-
hasStore: (name)=>
|
|
188
|
+
hasStore: (name)=>{
|
|
189
|
+
assertActive();
|
|
190
|
+
return this.#stores.has(name);
|
|
191
|
+
},
|
|
122
192
|
onCommit: (hook)=>{
|
|
193
|
+
assertActive();
|
|
123
194
|
scopedCommitHooks.push(hook);
|
|
124
195
|
},
|
|
125
196
|
onRollback: (hook)=>{
|
|
197
|
+
assertActive();
|
|
126
198
|
scopedRollbackHooks.push(hook);
|
|
127
199
|
},
|
|
200
|
+
withScope: async (nestedFn)=>{
|
|
201
|
+
assertActive();
|
|
202
|
+
return await nestedFn(provider);
|
|
203
|
+
},
|
|
128
204
|
withTransaction: async (nestedFn)=>{
|
|
129
205
|
// Already inside a transaction — Kysely does not nest.
|
|
130
206
|
// Run `nestedFn` inline with the same provider so its hooks and
|
|
131
207
|
// writes remain within the enclosing transaction or savepoint.
|
|
208
|
+
assertActive();
|
|
132
209
|
return await nestedFn(provider);
|
|
133
210
|
},
|
|
134
211
|
withSavepoint: async (nestedFn)=>{
|
|
135
|
-
|
|
136
|
-
savepointOpen
|
|
212
|
+
assertActive();
|
|
213
|
+
if (scopeState.savepointOpen) throw new SavepointOverlapError();
|
|
214
|
+
scopeState.savepointOpen = true;
|
|
137
215
|
try {
|
|
138
216
|
const name = `${this.#tablePrefix}_sp_${++savepointID}`;
|
|
139
217
|
const savepointCommitHooks = [];
|
|
140
218
|
const savepointRollbackHooks = [];
|
|
141
|
-
const
|
|
219
|
+
const savepointState = {
|
|
220
|
+
savepointOpen: false,
|
|
221
|
+
settled: false,
|
|
222
|
+
savepointBound: true
|
|
223
|
+
};
|
|
224
|
+
const scopedProvider = createScopedProvider(savepointCommitHooks, savepointRollbackHooks, provider.transactionRoot, provider, true, savepointState);
|
|
225
|
+
registry.dispatchAll();
|
|
142
226
|
await sql.raw(`SAVEPOINT ${name}`).execute(trx);
|
|
143
227
|
try {
|
|
144
228
|
const value = await nestedFn(scopedProvider);
|
|
229
|
+
registry.dispatchAll();
|
|
145
230
|
await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
|
|
146
|
-
|
|
147
|
-
|
|
231
|
+
savepointState.settled = true;
|
|
232
|
+
registry.clearAll();
|
|
233
|
+
for (const hook of savepointCommitHooks)provider.onCommit(hook);
|
|
234
|
+
for (const hook of savepointRollbackHooks)provider.onRollback(hook);
|
|
148
235
|
return value;
|
|
149
236
|
} catch (error) {
|
|
237
|
+
registry.dispatchAll();
|
|
150
238
|
await sql.raw(`ROLLBACK TO SAVEPOINT ${name}`).execute(trx);
|
|
239
|
+
registry.dispatchAll();
|
|
151
240
|
await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
|
|
241
|
+
savepointState.settled = true;
|
|
242
|
+
registry.clearAll();
|
|
152
243
|
this.#runHooks(savepointRollbackHooks, 'rollback');
|
|
153
244
|
throw error;
|
|
245
|
+
} finally{
|
|
246
|
+
savepointState.settled = true;
|
|
247
|
+
settleSyncProvider(scopedProvider);
|
|
154
248
|
}
|
|
155
249
|
} finally{
|
|
156
|
-
savepointOpen = false;
|
|
250
|
+
scopeState.savepointOpen = false;
|
|
157
251
|
}
|
|
158
252
|
}
|
|
159
253
|
};
|
|
254
|
+
bindSyncProvider(provider, {
|
|
255
|
+
assertActive,
|
|
256
|
+
derive: ()=>createScopedProvider(scopedCommitHooks, scopedRollbackHooks, provider.transactionRoot, provider, false, scopeState),
|
|
257
|
+
db: txKysely,
|
|
258
|
+
kind: this.#adapter.kind,
|
|
259
|
+
tablePrefix: this.#tablePrefix,
|
|
260
|
+
tableName: (table)=>{
|
|
261
|
+
const unprefixed = [
|
|
262
|
+
...this.#stores.values()
|
|
263
|
+
].some((store)=>store.unprefixedTables?.(table));
|
|
264
|
+
return unprefixed ? table : `${this.#tablePrefix}_${table}`;
|
|
265
|
+
}
|
|
266
|
+
}, syncParent, syncSavepoint);
|
|
160
267
|
return provider;
|
|
161
268
|
};
|
|
162
|
-
|
|
269
|
+
try {
|
|
270
|
+
return await fn(createScopedProvider(commitHooks, rollbackHooks));
|
|
271
|
+
} finally{
|
|
272
|
+
registry.dispatchAll();
|
|
273
|
+
}
|
|
163
274
|
});
|
|
164
275
|
} catch (err) {
|
|
276
|
+
registry.close();
|
|
165
277
|
// Kysely issues ROLLBACK and rejects `execute()` when the callback
|
|
166
278
|
// throws, so the DB is already reverted here. Run the rollback hooks to
|
|
167
279
|
// drop any in-memory state that was eagerly advanced expecting a commit
|
|
@@ -171,6 +283,8 @@ export class HozonDB {
|
|
|
171
283
|
this.#runHooks(rollbackHooks, 'rollback');
|
|
172
284
|
throw err;
|
|
173
285
|
}
|
|
286
|
+
registry.close();
|
|
287
|
+
beforeHooks?.();
|
|
174
288
|
// The transaction has committed; post-commit hooks are best-effort side
|
|
175
289
|
// effects (event emission, cache invalidation). A throwing hook must not
|
|
176
290
|
// skip the remaining hooks or make the caller observe a durable write as a
|
|
@@ -202,6 +316,17 @@ export class HozonDB {
|
|
|
202
316
|
return this.#closing;
|
|
203
317
|
}
|
|
204
318
|
async #close() {
|
|
319
|
+
// Publish #closing before a hook can re-enter close().
|
|
320
|
+
await Promise.resolve();
|
|
321
|
+
for (const hook of this.#closeHooks){
|
|
322
|
+
try {
|
|
323
|
+
await hook();
|
|
324
|
+
} catch (error) {
|
|
325
|
+
this.#logger.error('close hook failed', {
|
|
326
|
+
error
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
205
330
|
try {
|
|
206
331
|
await this.#db.destroy();
|
|
207
332
|
} finally{
|
|
@@ -236,6 +361,32 @@ export class HozonDB {
|
|
|
236
361
|
}
|
|
237
362
|
return migrations;
|
|
238
363
|
}
|
|
364
|
+
// Stores on the default plugin must share one Kysely instance: keep-set locks key on it.
|
|
365
|
+
#createStoreAPI(store, kysely, defaultDB, registry, provider, assertActive) {
|
|
366
|
+
const storeDB = store.unprefixedTables == null ? defaultDB : kysely.withPlugin(this.#getStorePlugin(store));
|
|
367
|
+
const base = {
|
|
368
|
+
compareWrite: async (table, row)=>{
|
|
369
|
+
assertActive?.();
|
|
370
|
+
return await compareWrite(storeDB, table, row, store.sync?.[table]);
|
|
371
|
+
},
|
|
372
|
+
createLoader: (params)=>{
|
|
373
|
+
assertActive?.();
|
|
374
|
+
return registry.createLoader(params);
|
|
375
|
+
},
|
|
376
|
+
provider,
|
|
377
|
+
root: this,
|
|
378
|
+
onClose: (fn)=>{
|
|
379
|
+
this.#assertOpen();
|
|
380
|
+
this.#closeHooks.push(fn);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
const ext = this.#context?.({
|
|
384
|
+
provider,
|
|
385
|
+
root: this
|
|
386
|
+
});
|
|
387
|
+
const context = Object.defineProperties(base, Object.getOwnPropertyDescriptors(ext ?? {}));
|
|
388
|
+
return store.createAPI(storeDB, this.#adapter, context);
|
|
389
|
+
}
|
|
239
390
|
#getStorePlugin(store) {
|
|
240
391
|
if (store.unprefixedTables == null) return this.#defaultPlugin;
|
|
241
392
|
let plugin = this.#storePlugins.get(store.name);
|
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,3 +16,6 @@ export declare class InvalidTablePrefixError extends Error {
|
|
|
10
16
|
export declare class HozonDBClosedError extends Error {
|
|
11
17
|
constructor();
|
|
12
18
|
}
|
|
19
|
+
export declare class BatchLimitError extends Error {
|
|
20
|
+
constructor(size: number, limit: number);
|
|
21
|
+
}
|
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,3 +34,9 @@ export class HozonDBClosedError extends Error {
|
|
|
22
34
|
this.name = 'HozonDBClosedError';
|
|
23
35
|
}
|
|
24
36
|
}
|
|
37
|
+
export class BatchLimitError extends Error {
|
|
38
|
+
constructor(size, limit){
|
|
39
|
+
super(`Batch of ${size} keys exceeds limit ${limit}`);
|
|
40
|
+
this.name = 'BatchLimitError';
|
|
41
|
+
}
|
|
42
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export type { ColumnType, Expression, Generated, Insertable, RawBuilder, Selectable, SelectQueryBuilder, Transaction, Updateable, } from 'kysely';
|
|
2
2
|
export { Kysely, sql } from 'kysely';
|
|
3
3
|
export type { Migration } from 'kysely/migration';
|
|
4
|
-
export
|
|
4
|
+
export { assertBatchSize, type BatchReadOptions, type IterateBatchesOptions, iterateBatches, } from './batch.js';
|
|
5
|
+
export { type BatchLoader, type BatchLoaderParams, createBatchLoader, } from './batch-loader.js';
|
|
6
|
+
export type { BaseStoreContext, ContextFactory, HozonDBParams, MigrationContext, StoreContext, StoreDefinition, StoreProvider, } from './db.js';
|
|
5
7
|
export { HozonDB } from './db.js';
|
|
6
|
-
export { HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SchemaVersionError, } from './errors.js';
|
|
7
|
-
export { chunk, withKeepSet } from './keep-set.js';
|
|
8
|
+
export { BatchLimitError, HozonDBClosedError, InvalidTablePrefixError, KeyLockScopeError, SavepointOverlapError, SavepointReleasedError, SchemaVersionError, } from './errors.js';
|
|
9
|
+
export { chunk, IN_LIST_CHUNK, withKeepSet } from './keep-set.js';
|
|
8
10
|
export { withStoreTransaction } from './store-transaction.js';
|
|
11
|
+
export { type CompareDefinition, compareText, withKeyLock, withKeyLocks } from './sync.js';
|
|
9
12
|
export { type TablePrefixOptions, TablePrefixPlugin } from './table-prefix.js';
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { Kysely, sql } from 'kysely';
|
|
2
|
+
export { assertBatchSize, iterateBatches } from './batch.js';
|
|
3
|
+
export { createBatchLoader } from './batch-loader.js';
|
|
2
4
|
export { HozonDB } from './db.js';
|
|
3
|
-
export { HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SchemaVersionError } from './errors.js';
|
|
4
|
-
export { chunk, withKeepSet } from './keep-set.js';
|
|
5
|
+
export { BatchLimitError, HozonDBClosedError, InvalidTablePrefixError, KeyLockScopeError, SavepointOverlapError, SavepointReleasedError, SchemaVersionError } from './errors.js';
|
|
6
|
+
export { chunk, IN_LIST_CHUNK, withKeepSet } from './keep-set.js';
|
|
5
7
|
export { withStoreTransaction } from './store-transaction.js';
|
|
8
|
+
export { compareText, withKeyLock, withKeyLocks } from './sync.js';
|
|
6
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
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type BatchLoader, type BatchLoaderParams } from './batch-loader.js';
|
|
2
|
+
export declare class LoaderRegistry {
|
|
3
|
+
#private;
|
|
4
|
+
constructor(params: {
|
|
5
|
+
remember: boolean;
|
|
6
|
+
});
|
|
7
|
+
createLoader<TKey, TValue>(params: BatchLoaderParams<TKey, TValue>): BatchLoader<TKey, TValue>;
|
|
8
|
+
dispatchAll(): void;
|
|
9
|
+
clearAll(): void;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createBatchLoader } from './batch-loader.js';
|
|
2
|
+
export class LoaderRegistry {
|
|
3
|
+
#remember;
|
|
4
|
+
#loaders = new Set();
|
|
5
|
+
constructor(params){
|
|
6
|
+
this.#remember = params.remember;
|
|
7
|
+
}
|
|
8
|
+
createLoader(params) {
|
|
9
|
+
const loader = createBatchLoader({
|
|
10
|
+
...params,
|
|
11
|
+
remember: ()=>this.#remember
|
|
12
|
+
});
|
|
13
|
+
this.#loaders.add(loader);
|
|
14
|
+
return loader;
|
|
15
|
+
}
|
|
16
|
+
dispatchAll() {
|
|
17
|
+
for (const loader of this.#loaders)loader.dispatch();
|
|
18
|
+
}
|
|
19
|
+
clearAll() {
|
|
20
|
+
for (const loader of this.#loaders)loader.clearAll();
|
|
21
|
+
}
|
|
22
|
+
close() {
|
|
23
|
+
this.#remember = false;
|
|
24
|
+
this.clearAll();
|
|
25
|
+
}
|
|
26
|
+
}
|
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.4.0",
|
|
4
4
|
"description": "Database registry, migrations and transactions for Hozon",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,15 +17,16 @@
|
|
|
17
17
|
"lib/*"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@hozon/adapter": "^0.
|
|
20
|
+
"@hozon/adapter": "^0.2.0",
|
|
21
|
+
"@sozai/async": "^0.4.1",
|
|
21
22
|
"@sozai/log": "^0.4.0",
|
|
22
|
-
"kysely": "^0.29.
|
|
23
|
+
"kysely": "^0.29.7"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
|
-
"@hozon/node-sqlite": "^0.
|
|
26
|
-
"@types/node": "^26.6.
|
|
27
|
-
"del-cli": "^
|
|
28
|
-
"vitest": "5.0.
|
|
26
|
+
"@hozon/node-sqlite": "^0.2.0",
|
|
27
|
+
"@types/node": "^26.6.5",
|
|
28
|
+
"del-cli": "^8.0.1",
|
|
29
|
+
"vitest": "5.0.5"
|
|
29
30
|
},
|
|
30
31
|
"engines": {
|
|
31
32
|
"node": ">=24"
|