@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.
@@ -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 StoreDefinition<Tables, API> = {
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
- createAPI: (db: Kysely<Tables>, adapter: Adapter) => API;
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
- export declare class HozonDB implements StoreProvider {
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
- register<Tables, API>(store: StoreDefinition<Tables, API>): void;
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
- const storeDB = store.unprefixedTables == null ? this.#storeDB : this.#db.withPlugin(this.#getStorePlugin(store));
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 txStoreAPIs = new Map();
104
- const createScopedProvider = (scopedCommitHooks, scopedRollbackHooks)=>{
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
- let savepointOpen = false;
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
- const storeDB = store.unprefixedTables == null ? txStoreDB : txKysely.withPlugin(this.#getStorePlugin(store));
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)=>this.#stores.has(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
- if (savepointOpen) throw new SavepointOverlapError();
136
- savepointOpen = true;
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 scopedProvider = createScopedProvider(savepointCommitHooks, savepointRollbackHooks);
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
- scopedCommitHooks.push(...savepointCommitHooks);
147
- scopedRollbackHooks.push(...savepointRollbackHooks);
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
- return await fn(createScopedProvider(commitHooks, rollbackHooks));
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 type { HozonDBParams, MigrationContext, StoreDefinition, StoreProvider } from './db.js';
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 function chunk<T>(items: Array<T>, size?: number): Array<Array<T>>;
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 function chunk(items, size = 500) {
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
- for(let offset = 0; offset < items.length; offset += size){
6
- chunks.push(items.slice(offset, offset + size));
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.2.3",
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.1.0",
20
+ "@hozon/adapter": "^0.2.0",
21
+ "@sozai/async": "^0.4.1",
21
22
  "@sozai/log": "^0.4.0",
22
- "kysely": "^0.29.6"
23
+ "kysely": "^0.29.7"
23
24
  },
24
25
  "devDependencies": {
25
- "@hozon/node-sqlite": "^0.1.0",
26
- "@types/node": "^26.6.4",
27
- "del-cli": "^7.0.0",
28
- "vitest": "5.0.3"
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"