@hozon/db 0.2.3 → 0.3.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,12 +2,16 @@ 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';
5
6
  export type MigrationContext = {
6
7
  tablePrefix: string;
7
8
  kind: Adapter['kind'];
8
9
  types: ColumnTypes;
9
10
  functions: Functions;
10
11
  };
12
+ export type StoreContext = {
13
+ createLoader<TKey, TValue>(params: BatchLoaderParams<TKey, TValue>): BatchLoader<TKey, TValue>;
14
+ };
11
15
  export type StoreDefinition<Tables, API> = {
12
16
  name: string;
13
17
  migrations: Record<string, Migration> | ((ctx: MigrationContext) => Record<string, Migration>);
@@ -18,7 +22,7 @@ export type StoreDefinition<Tables, API> = {
18
22
  * tables are not namespaced: the store must keep them collision-free.
19
23
  */
20
24
  unprefixedTables?: (name: string) => boolean;
21
- createAPI: (db: Kysely<Tables>, adapter: Adapter) => API;
25
+ createAPI: (db: Kysely<Tables>, adapter: Adapter, context: StoreContext) => API;
22
26
  };
23
27
  export type StoreProvider<Stores extends Record<string, unknown> = Record<string, unknown>> = {
24
28
  getStore<S extends keyof Stores & string>(name: S): Promise<Stores[S]>;
@@ -53,6 +57,7 @@ export type StoreProvider<Stores extends Record<string, unknown> = Record<string
53
57
  * transaction or explicit savepoint.
54
58
  */
55
59
  withTransaction<NestedStores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
60
+ withScope?<NestedStores extends Record<string, unknown>, R>(fn: (scope: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
56
61
  /** Roll back only this unit of work when called inside a transaction. */
57
62
  withSavepoint?<NestedStores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
58
63
  };
@@ -70,6 +75,7 @@ export declare class HozonDB implements StoreProvider {
70
75
  hasStore(name: string): boolean;
71
76
  onCommit(fn: () => void): void;
72
77
  onRollback(_fn: () => void): void;
78
+ withScope<Stores extends Record<string, unknown>, R>(fn: (scope: StoreProvider<Stores>) => Promise<R>): Promise<R>;
73
79
  /**
74
80
  * Runs `fn` inside a DB transaction and provides a scoped `StoreProvider`.
75
81
  * Concurrent calls on the same `HozonDB` instance are supported — the
package/lib/db.js CHANGED
@@ -3,6 +3,7 @@ import { Kysely, sql } from 'kysely';
3
3
  import { Migrator } from 'kysely/migration';
4
4
  import { withTransactionalDDL } from './dialect.js';
5
5
  import { HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError } from './errors.js';
6
+ import { LoaderRegistry } from './loader-registry.js';
6
7
  import { checkStore } from './preflight.js';
7
8
  import { TablePrefixPlugin } from './table-prefix.js';
8
9
  export class HozonDB {
@@ -19,6 +20,9 @@ export class HozonDB {
19
20
  #storePlugins = new Map();
20
21
  #logger;
21
22
  #migrated = new Map();
23
+ #loaders = new LoaderRegistry({
24
+ remember: false
25
+ });
22
26
  #storeAPIs = new Map();
23
27
  #stores = new Map();
24
28
  constructor(params){
@@ -63,8 +67,7 @@ export class HozonDB {
63
67
  if (store == null) {
64
68
  throw new Error(`Store "${name}" is not registered`);
65
69
  }
66
- const storeDB = store.unprefixedTables == null ? this.#storeDB : this.#db.withPlugin(this.#getStorePlugin(store));
67
- this.#storeAPIs.set(name, store.createAPI(storeDB, this.#adapter));
70
+ this.#storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, this.#loaders));
68
71
  }
69
72
  return this.#storeAPIs.get(name);
70
73
  }
@@ -76,6 +79,41 @@ export class HozonDB {
76
79
  }
77
80
  // Each standalone op auto-commits, so there is nothing to roll back — no-op.
78
81
  onRollback(_fn) {}
82
+ async withScope(fn) {
83
+ this.#assertOpen();
84
+ const registry = new LoaderRegistry({
85
+ remember: true
86
+ });
87
+ const storeAPIs = new Map();
88
+ const provider = {
89
+ getStore: async (name)=>{
90
+ this.#assertOpen();
91
+ await this.#ensurePreflight();
92
+ await this.#ensureMigrated(name);
93
+ this.#assertOpen();
94
+ if (!storeAPIs.has(name)) {
95
+ const store = this.#stores.get(name);
96
+ if (store == null) throw new Error(`Store "${name}" is not registered`);
97
+ storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, registry));
98
+ }
99
+ return storeAPIs.get(name);
100
+ },
101
+ hasStore: (name)=>this.hasStore(name),
102
+ onCommit: (hook)=>hook(),
103
+ onRollback: ()=>{},
104
+ withScope: async (nestedFn)=>{
105
+ return await nestedFn(provider);
106
+ },
107
+ withTransaction: async (nestedFn)=>{
108
+ return await this.#withTransaction(nestedFn, ()=>registry.clearAll());
109
+ }
110
+ };
111
+ try {
112
+ return await fn(provider);
113
+ } finally{
114
+ registry.close();
115
+ }
116
+ }
79
117
  /**
80
118
  * Runs `fn` inside a DB transaction and provides a scoped `StoreProvider`.
81
119
  * Concurrent calls on the same `HozonDB` instance are supported — the
@@ -85,6 +123,9 @@ export class HozonDB {
85
123
  * `db.withTransaction` on the same instance inside the callback opens a
86
124
  * new independent transaction (no atomic nesting) and is a bug.
87
125
  */ async withTransaction(fn) {
126
+ return await this.#withTransaction(fn);
127
+ }
128
+ async #withTransaction(fn, beforeHooks) {
88
129
  // Migrate every registered store before opening the transaction. Stores
89
130
  // are internal and known up front, so a broken migration should fail fast
90
131
  // at startup rather than leave a runtime partition where one store's
@@ -93,6 +134,9 @@ export class HozonDB {
93
134
  this.#assertOpen();
94
135
  const commitHooks = [];
95
136
  const rollbackHooks = [];
137
+ const registry = new LoaderRegistry({
138
+ remember: true
139
+ });
96
140
  let result;
97
141
  let savepointID = 0;
98
142
  try {
@@ -113,8 +157,7 @@ export class HozonDB {
113
157
  if (store == null) {
114
158
  throw new Error(`Store "${name}" is not registered`);
115
159
  }
116
- const storeDB = store.unprefixedTables == null ? txStoreDB : txKysely.withPlugin(this.#getStorePlugin(store));
117
- txStoreAPIs.set(name, store.createAPI(storeDB, this.#adapter));
160
+ txStoreAPIs.set(name, this.#createStoreAPI(store, txKysely, txStoreDB, registry));
118
161
  }
119
162
  return txStoreAPIs.get(name);
120
163
  },
@@ -125,6 +168,9 @@ export class HozonDB {
125
168
  onRollback: (hook)=>{
126
169
  scopedRollbackHooks.push(hook);
127
170
  },
171
+ withScope: async (nestedFn)=>{
172
+ return await nestedFn(provider);
173
+ },
128
174
  withTransaction: async (nestedFn)=>{
129
175
  // Already inside a transaction — Kysely does not nest.
130
176
  // Run `nestedFn` inline with the same provider so its hooks and
@@ -139,16 +185,21 @@ export class HozonDB {
139
185
  const savepointCommitHooks = [];
140
186
  const savepointRollbackHooks = [];
141
187
  const scopedProvider = createScopedProvider(savepointCommitHooks, savepointRollbackHooks);
188
+ registry.dispatchAll();
142
189
  await sql.raw(`SAVEPOINT ${name}`).execute(trx);
143
190
  try {
144
191
  const value = await nestedFn(scopedProvider);
192
+ registry.dispatchAll();
145
193
  await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
146
194
  scopedCommitHooks.push(...savepointCommitHooks);
147
195
  scopedRollbackHooks.push(...savepointRollbackHooks);
148
196
  return value;
149
197
  } catch (error) {
198
+ registry.dispatchAll();
150
199
  await sql.raw(`ROLLBACK TO SAVEPOINT ${name}`).execute(trx);
200
+ registry.dispatchAll();
151
201
  await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
202
+ registry.clearAll();
152
203
  this.#runHooks(savepointRollbackHooks, 'rollback');
153
204
  throw error;
154
205
  }
@@ -159,9 +210,14 @@ export class HozonDB {
159
210
  };
160
211
  return provider;
161
212
  };
162
- return await fn(createScopedProvider(commitHooks, rollbackHooks));
213
+ try {
214
+ return await fn(createScopedProvider(commitHooks, rollbackHooks));
215
+ } finally{
216
+ registry.dispatchAll();
217
+ }
163
218
  });
164
219
  } catch (err) {
220
+ registry.close();
165
221
  // Kysely issues ROLLBACK and rejects `execute()` when the callback
166
222
  // throws, so the DB is already reverted here. Run the rollback hooks to
167
223
  // drop any in-memory state that was eagerly advanced expecting a commit
@@ -171,6 +227,8 @@ export class HozonDB {
171
227
  this.#runHooks(rollbackHooks, 'rollback');
172
228
  throw err;
173
229
  }
230
+ registry.close();
231
+ beforeHooks?.();
174
232
  // The transaction has committed; post-commit hooks are best-effort side
175
233
  // effects (event emission, cache invalidation). A throwing hook must not
176
234
  // skip the remaining hooks or make the caller observe a durable write as a
@@ -236,6 +294,14 @@ export class HozonDB {
236
294
  }
237
295
  return migrations;
238
296
  }
297
+ // Hands stores only createLoader, so they cannot reach close/dispatchAll/clearAll.
298
+ // Stores on the default plugin must share one Kysely instance: keep-set locks key on it.
299
+ #createStoreAPI(store, kysely, defaultDB, registry) {
300
+ const storeDB = store.unprefixedTables == null ? defaultDB : kysely.withPlugin(this.#getStorePlugin(store));
301
+ return store.createAPI(storeDB, this.#adapter, {
302
+ createLoader: (params)=>registry.createLoader(params)
303
+ });
304
+ }
239
305
  #getStorePlugin(store) {
240
306
  if (store.unprefixedTables == null) return this.#defaultPlugin;
241
307
  let plugin = this.#storePlugins.get(store.name);
package/lib/errors.d.ts CHANGED
@@ -10,3 +10,6 @@ export declare class InvalidTablePrefixError extends Error {
10
10
  export declare class HozonDBClosedError extends Error {
11
11
  constructor();
12
12
  }
13
+ export declare class BatchLimitError extends Error {
14
+ constructor(size: number, limit: number);
15
+ }
package/lib/errors.js CHANGED
@@ -22,3 +22,9 @@ export class HozonDBClosedError extends Error {
22
22
  this.name = 'HozonDBClosedError';
23
23
  }
24
24
  }
25
+ export class BatchLimitError extends Error {
26
+ constructor(size, limit){
27
+ super(`Batch of ${size} keys exceeds limit ${limit}`);
28
+ this.name = 'BatchLimitError';
29
+ }
30
+ }
package/lib/index.d.ts CHANGED
@@ -1,9 +1,11 @@
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 { HozonDBParams, MigrationContext, StoreContext, StoreDefinition, StoreProvider, } from './db.js';
5
7
  export { HozonDB } from './db.js';
6
- export { HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SchemaVersionError, } from './errors.js';
8
+ export { BatchLimitError, HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SchemaVersionError, } from './errors.js';
7
9
  export { chunk, withKeepSet } from './keep-set.js';
8
10
  export { withStoreTransaction } from './store-transaction.js';
9
11
  export { type TablePrefixOptions, TablePrefixPlugin } from './table-prefix.js';
package/lib/index.js CHANGED
@@ -1,6 +1,8 @@
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';
5
+ export { BatchLimitError, HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SchemaVersionError } from './errors.js';
4
6
  export { chunk, withKeepSet } from './keep-set.js';
5
7
  export { withStoreTransaction } from './store-transaction.js';
6
8
  export { TablePrefixPlugin } from './table-prefix.js';
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hozon/db",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Database registry, migrations and transactions for Hozon",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,14 +18,15 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@hozon/adapter": "^0.1.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
26
  "@hozon/node-sqlite": "^0.1.0",
26
- "@types/node": "^26.6.4",
27
- "del-cli": "^7.0.0",
28
- "vitest": "5.0.3"
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"