@hozon/db 0.2.2 → 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
@@ -1,8 +1,9 @@
1
1
  import { getLogger } from '@sozai/log';
2
- import { Kysely, ParseJSONResultsPlugin, sql } from 'kysely';
2
+ 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){
@@ -30,10 +34,7 @@ export class HozonDB {
30
34
  this.#adapter = params.adapter;
31
35
  this.#db = new Kysely({
32
36
  // SQLite supports transactional DDL, but Kysely's SQLite adapters do not report it.
33
- dialect: this.#adapter.kind === 'sqlite' ? withTransactionalDDL(this.#adapter.dialect) : this.#adapter.dialect,
34
- plugins: [
35
- new ParseJSONResultsPlugin()
36
- ]
37
+ dialect: this.#adapter.kind === 'sqlite' ? withTransactionalDDL(this.#adapter.dialect) : this.#adapter.dialect
37
38
  });
38
39
  this.#storeDB = this.#db.withPlugin(this.#defaultPlugin);
39
40
  this.#logger = params.logger ?? getLogger([
@@ -66,8 +67,7 @@ export class HozonDB {
66
67
  if (store == null) {
67
68
  throw new Error(`Store "${name}" is not registered`);
68
69
  }
69
- const storeDB = store.unprefixedTables == null ? this.#storeDB : this.#db.withPlugin(this.#getStorePlugin(store));
70
- this.#storeAPIs.set(name, store.createAPI(storeDB, this.#adapter));
70
+ this.#storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, this.#loaders));
71
71
  }
72
72
  return this.#storeAPIs.get(name);
73
73
  }
@@ -79,6 +79,41 @@ export class HozonDB {
79
79
  }
80
80
  // Each standalone op auto-commits, so there is nothing to roll back — no-op.
81
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
+ }
82
117
  /**
83
118
  * Runs `fn` inside a DB transaction and provides a scoped `StoreProvider`.
84
119
  * Concurrent calls on the same `HozonDB` instance are supported — the
@@ -88,6 +123,9 @@ export class HozonDB {
88
123
  * `db.withTransaction` on the same instance inside the callback opens a
89
124
  * new independent transaction (no atomic nesting) and is a bug.
90
125
  */ async withTransaction(fn) {
126
+ return await this.#withTransaction(fn);
127
+ }
128
+ async #withTransaction(fn, beforeHooks) {
91
129
  // Migrate every registered store before opening the transaction. Stores
92
130
  // are internal and known up front, so a broken migration should fail fast
93
131
  // at startup rather than leave a runtime partition where one store's
@@ -96,6 +134,9 @@ export class HozonDB {
96
134
  this.#assertOpen();
97
135
  const commitHooks = [];
98
136
  const rollbackHooks = [];
137
+ const registry = new LoaderRegistry({
138
+ remember: true
139
+ });
99
140
  let result;
100
141
  let savepointID = 0;
101
142
  try {
@@ -116,8 +157,7 @@ export class HozonDB {
116
157
  if (store == null) {
117
158
  throw new Error(`Store "${name}" is not registered`);
118
159
  }
119
- const storeDB = store.unprefixedTables == null ? txStoreDB : txKysely.withPlugin(this.#getStorePlugin(store));
120
- txStoreAPIs.set(name, store.createAPI(storeDB, this.#adapter));
160
+ txStoreAPIs.set(name, this.#createStoreAPI(store, txKysely, txStoreDB, registry));
121
161
  }
122
162
  return txStoreAPIs.get(name);
123
163
  },
@@ -128,6 +168,9 @@ export class HozonDB {
128
168
  onRollback: (hook)=>{
129
169
  scopedRollbackHooks.push(hook);
130
170
  },
171
+ withScope: async (nestedFn)=>{
172
+ return await nestedFn(provider);
173
+ },
131
174
  withTransaction: async (nestedFn)=>{
132
175
  // Already inside a transaction — Kysely does not nest.
133
176
  // Run `nestedFn` inline with the same provider so its hooks and
@@ -142,16 +185,21 @@ export class HozonDB {
142
185
  const savepointCommitHooks = [];
143
186
  const savepointRollbackHooks = [];
144
187
  const scopedProvider = createScopedProvider(savepointCommitHooks, savepointRollbackHooks);
188
+ registry.dispatchAll();
145
189
  await sql.raw(`SAVEPOINT ${name}`).execute(trx);
146
190
  try {
147
191
  const value = await nestedFn(scopedProvider);
192
+ registry.dispatchAll();
148
193
  await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
149
194
  scopedCommitHooks.push(...savepointCommitHooks);
150
195
  scopedRollbackHooks.push(...savepointRollbackHooks);
151
196
  return value;
152
197
  } catch (error) {
198
+ registry.dispatchAll();
153
199
  await sql.raw(`ROLLBACK TO SAVEPOINT ${name}`).execute(trx);
200
+ registry.dispatchAll();
154
201
  await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
202
+ registry.clearAll();
155
203
  this.#runHooks(savepointRollbackHooks, 'rollback');
156
204
  throw error;
157
205
  }
@@ -162,9 +210,14 @@ export class HozonDB {
162
210
  };
163
211
  return provider;
164
212
  };
165
- return await fn(createScopedProvider(commitHooks, rollbackHooks));
213
+ try {
214
+ return await fn(createScopedProvider(commitHooks, rollbackHooks));
215
+ } finally{
216
+ registry.dispatchAll();
217
+ }
166
218
  });
167
219
  } catch (err) {
220
+ registry.close();
168
221
  // Kysely issues ROLLBACK and rejects `execute()` when the callback
169
222
  // throws, so the DB is already reverted here. Run the rollback hooks to
170
223
  // drop any in-memory state that was eagerly advanced expecting a commit
@@ -174,6 +227,8 @@ export class HozonDB {
174
227
  this.#runHooks(rollbackHooks, 'rollback');
175
228
  throw err;
176
229
  }
230
+ registry.close();
231
+ beforeHooks?.();
177
232
  // The transaction has committed; post-commit hooks are best-effort side
178
233
  // effects (event emission, cache invalidation). A throwing hook must not
179
234
  // skip the remaining hooks or make the caller observe a durable write as a
@@ -239,6 +294,14 @@ export class HozonDB {
239
294
  }
240
295
  return migrations;
241
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
+ }
242
305
  #getStorePlugin(store) {
243
306
  if (store.unprefixedTables == null) return this.#defaultPlugin;
244
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.2",
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"