@hozon/db 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paul Le Cam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,18 @@
1
- # Temporary Holding Version
1
+ # @hozon/db
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Database lifecycle, store registration, migrations, transactions, and savepoints.
4
+
5
+ ```sh
6
+ pnpm add @hozon/db @hozon/node-sqlite
7
+ ```
8
+
9
+ ```ts
10
+ import { HozonDB } from '@hozon/db'
11
+ import { NodeSQLiteAdapter } from '@hozon/node-sqlite'
12
+
13
+ const db = new HozonDB({ adapter: new NodeSQLiteAdapter({ database: 'app.sqlite' }) })
14
+ await db.migrate()
15
+ await db.close()
16
+ ```
17
+
18
+ See the [database reference](../../docs/reference/db.md).
package/lib/db.d.ts ADDED
@@ -0,0 +1,79 @@
1
+ import type { Adapter, ColumnTypes, Functions } from '@hozon/adapter';
2
+ import { type Logger } from '@sozai/log';
3
+ import { Kysely } from 'kysely';
4
+ import { type Migration } from 'kysely/migration';
5
+ export type MigrationContext = {
6
+ tablePrefix: string;
7
+ kind: Adapter['kind'];
8
+ types: ColumnTypes;
9
+ functions: Functions;
10
+ };
11
+ export type StoreDefinition<Tables, API> = {
12
+ name: string;
13
+ migrations: Record<string, Migration> | ((ctx: MigrationContext) => Record<string, Migration>);
14
+ dependsOn?: Array<string>;
15
+ createAPI: (db: Kysely<Tables>, adapter: Adapter) => API;
16
+ };
17
+ export type StoreProvider<Stores extends Record<string, unknown> = Record<string, unknown>> = {
18
+ getStore<S extends keyof Stores & string>(name: S): Promise<Stores[S]>;
19
+ /**
20
+ * Returns whether a store definition is registered, synchronously and
21
+ * WITHOUT triggering migration. This distinguishes "plugin not installed"
22
+ * (registration absent) from "plugin installed but its store currently
23
+ * errors" — callers can fail closed on the latter instead of mistaking a
24
+ * transient store error for an uninstalled plugin.
25
+ */
26
+ hasStore(name: string): boolean;
27
+ onCommit(fn: () => void): void;
28
+ /**
29
+ * Register a hook to run after the transaction rolls back. By the time the
30
+ * hook fires the DB is already reverted, so it is the correct place to drop
31
+ * any in-memory state that was eagerly advanced in anticipation of a commit
32
+ * (e.g. cache invalidation). Like `onCommit`, hooks are isolated and their
33
+ * errors logged, never re-thrown. On the non-transactional provider it is a
34
+ * no-op (each standalone op auto-commits; nothing to roll back).
35
+ */
36
+ onRollback(fn: () => void): void;
37
+ /**
38
+ * Run `fn` against a `StoreProvider` that scopes its writes to a transaction.
39
+ *
40
+ * On `HozonDB`, opens a fresh DB transaction and provides a transactional
41
+ * `tx` provider; the outer provider's `onCommit` hooks fire after commit.
42
+ *
43
+ * On a transactional `tx` provider obtained from a parent
44
+ * `withTransaction` call, this method runs `fn` inline with `this` as the
45
+ * nested `tx`. The underlying driver does not nest transactions
46
+ * (no implicit savepoints), so atomicity is inherited from the enclosing
47
+ * transaction or explicit savepoint.
48
+ */
49
+ withTransaction<NestedStores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
50
+ /** Roll back only this unit of work when called inside a transaction. */
51
+ withSavepoint?<NestedStores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
52
+ };
53
+ export type HozonDBParams = {
54
+ adapter: Adapter;
55
+ logger?: Logger;
56
+ tablePrefix?: string;
57
+ };
58
+ export declare class HozonDB implements StoreProvider {
59
+ #private;
60
+ constructor(params: HozonDBParams);
61
+ get adapter(): Adapter;
62
+ register<Tables, API>(store: StoreDefinition<Tables, API>): void;
63
+ getStore<T>(name: string): Promise<T>;
64
+ hasStore(name: string): boolean;
65
+ onCommit(fn: () => void): void;
66
+ onRollback(_fn: () => void): void;
67
+ /**
68
+ * Runs `fn` inside a DB transaction and provides a scoped `StoreProvider`.
69
+ * Concurrent calls on the same `HozonDB` instance are supported — the
70
+ * underlying driver serializes them.
71
+ *
72
+ * Callers MUST use the provided `tx` inside the callback. Calling
73
+ * `db.withTransaction` on the same instance inside the callback opens a
74
+ * new independent transaction (no atomic nesting) and is a bug.
75
+ */
76
+ withTransaction<Stores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<Stores>) => Promise<R>): Promise<R>;
77
+ migrate(): Promise<void>;
78
+ close(): Promise<void>;
79
+ }
package/lib/db.js ADDED
@@ -0,0 +1,301 @@
1
+ import { getLogger } from '@sozai/log';
2
+ import { Kysely, ParseJSONResultsPlugin, sql } from 'kysely';
3
+ import { Migrator } from 'kysely/migration';
4
+ import { withTransactionalDDL } from './dialect.js';
5
+ import { HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError } from './errors.js';
6
+ import { checkStore } from './preflight.js';
7
+ import { TablePrefixPlugin } from './table-prefix.js';
8
+ export class HozonDB {
9
+ #tablePrefix;
10
+ #closed = false;
11
+ #closing = null;
12
+ #preflight = null;
13
+ #checked = new Set();
14
+ #migrations = new Map();
15
+ #adapter;
16
+ #db;
17
+ #storeDB;
18
+ #logger;
19
+ #migrated = new Map();
20
+ #storeAPIs = new Map();
21
+ #stores = new Map();
22
+ constructor(params){
23
+ this.#tablePrefix = params.tablePrefix ?? 'hozon';
24
+ if (!/^[a-z][a-z0-9_]{0,30}$/.test(this.#tablePrefix)) {
25
+ throw new InvalidTablePrefixError(this.#tablePrefix);
26
+ }
27
+ this.#adapter = params.adapter;
28
+ this.#db = new Kysely({
29
+ // SQLite supports transactional DDL, but Kysely's SQLite adapters do not report it.
30
+ dialect: this.#adapter.kind === 'sqlite' ? withTransactionalDDL(this.#adapter.dialect) : this.#adapter.dialect,
31
+ plugins: [
32
+ new ParseJSONResultsPlugin()
33
+ ]
34
+ });
35
+ this.#storeDB = this.#db.withPlugin(new TablePrefixPlugin(this.#tablePrefix));
36
+ this.#logger = params.logger ?? getLogger([
37
+ 'hozon',
38
+ 'db'
39
+ ]);
40
+ }
41
+ get adapter() {
42
+ return this.#adapter;
43
+ }
44
+ register(store) {
45
+ const existing = this.#stores.get(store.name);
46
+ if (existing != null) {
47
+ if (existing !== store) {
48
+ this.#logger.warn('store "{name}" registered with different definition, ignoring', {
49
+ name: store.name
50
+ });
51
+ }
52
+ return;
53
+ }
54
+ this.#stores.set(store.name, store);
55
+ }
56
+ async getStore(name) {
57
+ this.#assertOpen();
58
+ await this.#ensurePreflight();
59
+ await this.#ensureMigrated(name);
60
+ this.#assertOpen();
61
+ if (!this.#storeAPIs.has(name)) {
62
+ const store = this.#stores.get(name);
63
+ if (store == null) {
64
+ throw new Error(`Store "${name}" is not registered`);
65
+ }
66
+ this.#storeAPIs.set(name, store.createAPI(this.#storeDB, this.#adapter));
67
+ }
68
+ return this.#storeAPIs.get(name);
69
+ }
70
+ hasStore(name) {
71
+ return this.#stores.has(name);
72
+ }
73
+ onCommit(fn) {
74
+ fn();
75
+ }
76
+ // Each standalone op auto-commits, so there is nothing to roll back — no-op.
77
+ onRollback(_fn) {}
78
+ /**
79
+ * Runs `fn` inside a DB transaction and provides a scoped `StoreProvider`.
80
+ * Concurrent calls on the same `HozonDB` instance are supported — the
81
+ * underlying driver serializes them.
82
+ *
83
+ * Callers MUST use the provided `tx` inside the callback. Calling
84
+ * `db.withTransaction` on the same instance inside the callback opens a
85
+ * new independent transaction (no atomic nesting) and is a bug.
86
+ */ async withTransaction(fn) {
87
+ // Migrate every registered store before opening the transaction. Stores
88
+ // are internal and known up front, so a broken migration should fail fast
89
+ // at startup rather than leave a runtime partition where one store's
90
+ // failure silently blocks transactions that never touch it.
91
+ await this.migrate();
92
+ this.#assertOpen();
93
+ const commitHooks = [];
94
+ const rollbackHooks = [];
95
+ let result;
96
+ let savepointID = 0;
97
+ try {
98
+ result = await this.#storeDB.transaction().execute(async (trx)=>{
99
+ const txKysely = trx;
100
+ const txStoreAPIs = new Map();
101
+ const createScopedProvider = (scopedCommitHooks, scopedRollbackHooks)=>{
102
+ // Savepoints on one connection form a stack: a scope holds at most
103
+ // one open savepoint, and nesting goes through that savepoint's
104
+ // own provider.
105
+ let savepointOpen = false;
106
+ const provider = {
107
+ getStore: async (name)=>{
108
+ if (!txStoreAPIs.has(name)) {
109
+ const store = this.#stores.get(name);
110
+ if (store == null) {
111
+ throw new Error(`Store "${name}" is not registered`);
112
+ }
113
+ txStoreAPIs.set(name, store.createAPI(txKysely, this.#adapter));
114
+ }
115
+ return txStoreAPIs.get(name);
116
+ },
117
+ hasStore: (name)=>this.#stores.has(name),
118
+ onCommit: (hook)=>{
119
+ scopedCommitHooks.push(hook);
120
+ },
121
+ onRollback: (hook)=>{
122
+ scopedRollbackHooks.push(hook);
123
+ },
124
+ withTransaction: async (nestedFn)=>{
125
+ // Already inside a transaction — Kysely does not nest.
126
+ // Run `nestedFn` inline with the same provider so its hooks and
127
+ // writes remain within the enclosing transaction or savepoint.
128
+ return await nestedFn(provider);
129
+ },
130
+ withSavepoint: async (nestedFn)=>{
131
+ if (savepointOpen) throw new SavepointOverlapError();
132
+ savepointOpen = true;
133
+ try {
134
+ const name = `${this.#tablePrefix}_sp_${++savepointID}`;
135
+ const savepointCommitHooks = [];
136
+ const savepointRollbackHooks = [];
137
+ const scopedProvider = createScopedProvider(savepointCommitHooks, savepointRollbackHooks);
138
+ await sql.raw(`SAVEPOINT ${name}`).execute(trx);
139
+ try {
140
+ const value = await nestedFn(scopedProvider);
141
+ await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
142
+ scopedCommitHooks.push(...savepointCommitHooks);
143
+ scopedRollbackHooks.push(...savepointRollbackHooks);
144
+ return value;
145
+ } catch (error) {
146
+ await sql.raw(`ROLLBACK TO SAVEPOINT ${name}`).execute(trx);
147
+ await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
148
+ this.#runHooks(savepointRollbackHooks, 'rollback');
149
+ throw error;
150
+ }
151
+ } finally{
152
+ savepointOpen = false;
153
+ }
154
+ }
155
+ };
156
+ return provider;
157
+ };
158
+ return await fn(createScopedProvider(commitHooks, rollbackHooks));
159
+ });
160
+ } catch (err) {
161
+ // Kysely issues ROLLBACK and rejects `execute()` when the callback
162
+ // throws, so the DB is already reverted here. Run the rollback hooks to
163
+ // drop any in-memory state that was eagerly advanced expecting a commit
164
+ // (revert first, then drop the stale cache). Hooks are isolated/logged,
165
+ // never re-thrown; the original error is preserved so callers (e.g. the
166
+ // engine's `MutateGraphWriteRollback` sentinel) still observe it.
167
+ this.#runHooks(rollbackHooks, 'rollback');
168
+ throw err;
169
+ }
170
+ // The transaction has committed; post-commit hooks are best-effort side
171
+ // effects (event emission, cache invalidation). A throwing hook must not
172
+ // skip the remaining hooks or make the caller observe a durable write as a
173
+ // failure, so each hook is isolated and its error logged, never re-thrown.
174
+ this.#runHooks(commitHooks, 'commit');
175
+ return result;
176
+ }
177
+ #runHooks(hooks, phase) {
178
+ for (const [index, hook] of hooks.entries()){
179
+ try {
180
+ hook();
181
+ } catch (error) {
182
+ this.#logger.error(`${phase} hook {index}/{count} failed`, {
183
+ index,
184
+ count: hooks.length,
185
+ error
186
+ });
187
+ }
188
+ }
189
+ }
190
+ async migrate() {
191
+ this.#assertOpen();
192
+ await this.#ensurePreflight();
193
+ await Promise.all(Array.from(this.#stores.keys()).map((name)=>this.#ensureMigrated(name)));
194
+ }
195
+ close() {
196
+ this.#closed = true;
197
+ this.#closing ??= this.#close();
198
+ return this.#closing;
199
+ }
200
+ async #close() {
201
+ try {
202
+ await this.#db.destroy();
203
+ } finally{
204
+ await this.#adapter.close?.();
205
+ }
206
+ this.#logger.info('closed');
207
+ }
208
+ #assertOpen() {
209
+ if (this.#closed) throw new HozonDBClosedError();
210
+ }
211
+ #getMigrations(store) {
212
+ let migrations = this.#migrations.get(store.name);
213
+ if (migrations == null) {
214
+ const storeMigrations = typeof store.migrations === 'function' ? store.migrations({
215
+ tablePrefix: this.#tablePrefix,
216
+ kind: this.#adapter.kind,
217
+ types: this.#adapter.types,
218
+ functions: this.#adapter.functions
219
+ }) : store.migrations;
220
+ const plugin = new TablePrefixPlugin(this.#tablePrefix);
221
+ migrations = Object.fromEntries(Object.entries(storeMigrations).map(([name, migration])=>{
222
+ const down = migration.down;
223
+ return [
224
+ name,
225
+ {
226
+ up: (db)=>migration.up(db.withPlugin(plugin)),
227
+ down: down && ((db)=>down.call(migration, db.withPlugin(plugin)))
228
+ }
229
+ ];
230
+ }));
231
+ this.#migrations.set(store.name, migrations);
232
+ }
233
+ return migrations;
234
+ }
235
+ #ensurePreflight() {
236
+ // Snapshot before awaiting so concurrent triggers share one read-only gate.
237
+ // A failed attempt clears the gate so the next caller reruns the read-only checks,
238
+ // then preparation; a successful attempt stays cached.
239
+ if (this.#preflight == null) {
240
+ const attempt = this.#runPreflight(Array.from(this.#stores.values())).catch((err)=>{
241
+ if (this.#preflight === attempt) this.#preflight = null;
242
+ throw err;
243
+ });
244
+ this.#preflight = attempt;
245
+ }
246
+ return this.#preflight;
247
+ }
248
+ async #runPreflight(stores) {
249
+ for (const store of stores){
250
+ await checkStore(this.#db, this.#tablePrefix, store.name, Object.keys(this.#getMigrations(store)));
251
+ this.#checked.add(store.name);
252
+ }
253
+ this.#assertOpen();
254
+ await this.#adapter.prepare?.();
255
+ }
256
+ async #ensureMigrated(name) {
257
+ let pending = this.#migrated.get(name);
258
+ if (pending == null) {
259
+ pending = this.#runStoreMigrations(name).catch((err)=>{
260
+ this.#migrated.delete(name);
261
+ throw err;
262
+ });
263
+ this.#migrated.set(name, pending);
264
+ }
265
+ return pending;
266
+ }
267
+ async #runStoreMigrations(name) {
268
+ const store = this.#stores.get(name);
269
+ if (store == null) {
270
+ throw new Error(`Store "${name}" is not registered`);
271
+ }
272
+ this.#assertOpen();
273
+ const migrations = this.#getMigrations(store);
274
+ if (!this.#checked.has(name)) {
275
+ await checkStore(this.#db, this.#tablePrefix, name, Object.keys(migrations));
276
+ this.#checked.add(name);
277
+ }
278
+ for (const dep of store.dependsOn ?? []){
279
+ await this.#ensureMigrated(dep);
280
+ }
281
+ this.#assertOpen();
282
+ if (Object.keys(migrations).length === 0) {
283
+ return;
284
+ }
285
+ const migrator = new Migrator({
286
+ db: this.#db,
287
+ provider: {
288
+ getMigrations: ()=>Promise.resolve(migrations)
289
+ },
290
+ migrationTableName: `${this.#tablePrefix}_${name}_migration`,
291
+ migrationLockTableName: `${this.#tablePrefix}_${name}_migration_lock`
292
+ });
293
+ const result = await migrator.migrateToLatest();
294
+ if (result.error != null) {
295
+ throw result.error;
296
+ }
297
+ this.#logger.info('store {name} migrations complete', {
298
+ name
299
+ });
300
+ }
301
+ }
@@ -0,0 +1,3 @@
1
+ import type { Dialect } from 'kysely';
2
+ /** Wraps a SQLite dialect so Kysely's Migrator runs each migration run in a transaction. */
3
+ export declare function withTransactionalDDL(dialect: Dialect): Dialect;
package/lib/dialect.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Delegates to a SQLite dialect adapter but reports transactional DDL support.
3
+ *
4
+ * Kysely's SQLite adapters report `supportsTransactionalDdl = false`, so its Migrator runs
5
+ * SQLite migrations outside a transaction and a failed migration leaves partial schema
6
+ * behind. SQLite does support transactional DDL, so HozonDB opts in for every SQLite
7
+ * driver regardless of which dialect it supplies.
8
+ */ class TransactionalDDLAdapter {
9
+ #adapter;
10
+ constructor(adapter){
11
+ this.#adapter = adapter;
12
+ }
13
+ get supportsCreateIfNotExists() {
14
+ return this.#adapter.supportsCreateIfNotExists;
15
+ }
16
+ get supportsMultipleConnections() {
17
+ return this.#adapter.supportsMultipleConnections;
18
+ }
19
+ get supportsTransactionalDdl() {
20
+ return true;
21
+ }
22
+ get supportsReturning() {
23
+ return this.#adapter.supportsReturning;
24
+ }
25
+ get supportsOutput() {
26
+ return this.#adapter.supportsOutput;
27
+ }
28
+ // biome-ignore lint/suspicious/noExplicitAny: matches Kysely's DialectAdapter signature.
29
+ acquireMigrationLock(db, options) {
30
+ return this.#adapter.acquireMigrationLock(db, options);
31
+ }
32
+ // biome-ignore lint/suspicious/noExplicitAny: matches Kysely's DialectAdapter signature.
33
+ releaseMigrationLock(db, options) {
34
+ return this.#adapter.releaseMigrationLock(db, options);
35
+ }
36
+ }
37
+ class TransactionalDDLDialect {
38
+ #dialect;
39
+ constructor(dialect){
40
+ this.#dialect = dialect;
41
+ }
42
+ createAdapter() {
43
+ return new TransactionalDDLAdapter(this.#dialect.createAdapter());
44
+ }
45
+ createDriver() {
46
+ return this.#dialect.createDriver();
47
+ }
48
+ // biome-ignore lint/suspicious/noExplicitAny: matches Kysely's Dialect signature.
49
+ createIntrospector(db) {
50
+ return this.#dialect.createIntrospector(db);
51
+ }
52
+ createQueryCompiler() {
53
+ return this.#dialect.createQueryCompiler();
54
+ }
55
+ }
56
+ /** Wraps a SQLite dialect so Kysely's Migrator runs each migration run in a transaction. */ export function withTransactionalDDL(dialect) {
57
+ return new TransactionalDDLDialect(dialect);
58
+ }
@@ -0,0 +1,12 @@
1
+ export declare class SavepointOverlapError extends Error {
2
+ constructor();
3
+ }
4
+ export declare class SchemaVersionError extends Error {
5
+ constructor(store: string, unknown: Array<string>);
6
+ }
7
+ export declare class InvalidTablePrefixError extends Error {
8
+ constructor(prefix: string);
9
+ }
10
+ export declare class HozonDBClosedError extends Error {
11
+ constructor();
12
+ }
package/lib/errors.js ADDED
@@ -0,0 +1,24 @@
1
+ export class SavepointOverlapError extends Error {
2
+ constructor(){
3
+ super('A savepoint is already open in this transaction scope');
4
+ this.name = 'SavepointOverlapError';
5
+ }
6
+ }
7
+ export class SchemaVersionError extends Error {
8
+ constructor(store, unknown){
9
+ super(`Database schema for store "${store}" is newer than this version supports (unknown migrations: ${unknown.join(', ')})`);
10
+ this.name = 'SchemaVersionError';
11
+ }
12
+ }
13
+ export class InvalidTablePrefixError extends Error {
14
+ constructor(prefix){
15
+ super(`Invalid table prefix "${prefix}": expected /^[a-z][a-z0-9_]{0,30}$/`);
16
+ this.name = 'InvalidTablePrefixError';
17
+ }
18
+ }
19
+ export class HozonDBClosedError extends Error {
20
+ constructor(){
21
+ super('HozonDB is closed');
22
+ this.name = 'HozonDBClosedError';
23
+ }
24
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type { HozonDBParams, MigrationContext, StoreDefinition, StoreProvider } from './db.js';
2
+ export { HozonDB } from './db.js';
3
+ export { HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SchemaVersionError, } from './errors.js';
4
+ export { chunk, withKeepSet } from './keep-set.js';
5
+ export { withStoreTransaction } from './store-transaction.js';
6
+ export { TablePrefixPlugin } from './table-prefix.js';
package/lib/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { HozonDB } from './db.js';
2
+ export { HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SchemaVersionError } from './errors.js';
3
+ export { chunk, withKeepSet } from './keep-set.js';
4
+ export { withStoreTransaction } from './store-transaction.js';
5
+ export { TablePrefixPlugin } from './table-prefix.js';
@@ -0,0 +1,19 @@
1
+ import type { Kysely, SelectQueryBuilder } from 'kysely';
2
+ export declare function chunk<T>(items: Array<T>, size?: number): Array<Array<T>>;
3
+ type KeepTable = {
4
+ keep: {
5
+ trace_id: string;
6
+ };
7
+ };
8
+ /**
9
+ * Runs `fn` with a temporary keep table filled with `ids`, dropping it afterwards.
10
+ * Requires a transaction. Concurrent calls on the same transaction and table are
11
+ * serialized in call order; `fn` must not call `withKeepSet` for the same table.
12
+ */
13
+ export declare function withKeepSet<DB, R>(db: Kysely<DB>, params: {
14
+ table: string;
15
+ ids: Array<string>;
16
+ }, fn: (selectKeep: () => SelectQueryBuilder<KeepTable, 'keep', {
17
+ trace_id: string;
18
+ }>) => Promise<R>): Promise<R>;
19
+ export {};
@@ -0,0 +1,67 @@
1
+ import { sql } from 'kysely';
2
+ export function chunk(items, size = 500) {
3
+ if (!Number.isInteger(size) || size <= 0) throw new RangeError('Chunk size must be a positive integer');
4
+ const chunks = [];
5
+ for(let offset = 0; offset < items.length; offset += size){
6
+ chunks.push(items.slice(offset, offset + size));
7
+ }
8
+ return chunks;
9
+ }
10
+ // Per-transaction keep-set locks, keyed by table name. Store operations reuse the
11
+ // caller's transaction (see withStoreTransaction), so concurrent calls sharing one
12
+ // transaction would otherwise create the same temp table twice. Each call waits for
13
+ // the previous holder of that (transaction, table) pair, in call order.
14
+ const keepSetLocks = new WeakMap();
15
+ async function withKeepSetLock(trx, table, fn) {
16
+ let locks = keepSetLocks.get(trx);
17
+ if (locks == null) {
18
+ locks = new Map();
19
+ keepSetLocks.set(trx, locks);
20
+ }
21
+ const previous = locks.get(table) ?? Promise.resolve();
22
+ let release;
23
+ const current = new Promise((resolve)=>{
24
+ release = resolve;
25
+ });
26
+ const tail = previous.then(()=>current);
27
+ locks.set(table, tail);
28
+ await previous;
29
+ try {
30
+ return await fn();
31
+ } finally{
32
+ release();
33
+ if (locks.get(table) === tail) locks.delete(table);
34
+ }
35
+ }
36
+ /**
37
+ * Runs `fn` with a temporary keep table filled with `ids`, dropping it afterwards.
38
+ * Requires a transaction. Concurrent calls on the same transaction and table are
39
+ * serialized in call order; `fn` must not call `withKeepSet` for the same table.
40
+ */ export async function withKeepSet(db, params, fn) {
41
+ if (!db.isTransaction) throw new Error('withKeepSet requires a transaction');
42
+ return withKeepSetLock(db, params.table, ()=>runKeepSet(db, params, fn));
43
+ }
44
+ async function runKeepSet(db, params, fn) {
45
+ await sql`CREATE TEMP TABLE ${sql.table(params.table)} (trace_id text PRIMARY KEY)`.execute(db);
46
+ let failed = false;
47
+ try {
48
+ const keepDB = db;
49
+ for (const ids of chunk(params.ids)){
50
+ await keepDB.insertInto(params.table).values(ids.map((traceID)=>({
51
+ trace_id: traceID
52
+ }))).onConflict((conflict)=>conflict.doNothing()).execute();
53
+ }
54
+ return await fn(()=>keepDB.selectFrom(`${params.table} as keep`).select('trace_id'));
55
+ } catch (error) {
56
+ failed = true;
57
+ throw error;
58
+ } finally{
59
+ try {
60
+ await sql`DROP TABLE ${sql.table(params.table)}`.execute(db);
61
+ } catch (error) {
62
+ // Cleanup must not replace the primary failure, including an aborted transaction.
63
+ // biome-ignore lint/correctness/noUnsafeFinally: Surface cleanup failure only without a primary failure.
64
+ if (!failed) throw error;
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,2 @@
1
+ import type { Kysely } from 'kysely';
2
+ export declare function checkStore<DB>(db: Kysely<DB>, prefix: string, name: string, keys: Array<string>): Promise<void>;
@@ -0,0 +1,18 @@
1
+ import { sql } from 'kysely';
2
+ import { SchemaVersionError } from './errors.js';
3
+ export async function checkStore(db, prefix, name, keys) {
4
+ let rows;
5
+ try {
6
+ const result = await sql`SELECT name FROM ${sql.table(`${prefix}_${name}_migration`)}`.execute(db);
7
+ rows = result.rows;
8
+ } catch (error) {
9
+ // Only a missing table is a fresh schema. Connection and permission errors must surface.
10
+ if (error instanceof Error && ('code' in error && error.code === '42P01' || /no such table:/i.test(error.message))) {
11
+ return;
12
+ }
13
+ throw error;
14
+ }
15
+ const known = new Set(keys);
16
+ const unknown = rows.map((row)=>row.name).filter((key)=>!known.has(key)).sort();
17
+ if (unknown.length > 0) throw new SchemaVersionError(name, unknown);
18
+ }
@@ -0,0 +1,2 @@
1
+ import type { Kysely } from 'kysely';
2
+ export declare function withStoreTransaction<DB, R>(db: Kysely<DB>, fn: (trx: Kysely<DB>) => Promise<R>): Promise<R>;
@@ -0,0 +1,3 @@
1
+ export async function withStoreTransaction(db, fn) {
2
+ return db.isTransaction ? fn(db) : db.transaction().execute(fn);
3
+ }
@@ -0,0 +1,7 @@
1
+ import type { KyselyPlugin, PluginTransformQueryArgs, PluginTransformResultArgs, QueryResult, RootOperationNode, UnknownRow } from 'kysely';
2
+ export declare class TablePrefixPlugin implements KyselyPlugin {
3
+ #private;
4
+ constructor(prefix: string);
5
+ transformQuery({ node, queryId }: PluginTransformQueryArgs): RootOperationNode;
6
+ transformResult({ result }: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>>;
7
+ }
@@ -0,0 +1,247 @@
1
+ import { AliasNode, IdentifierNode, JoinNode, ListNode, OperationNodeTransformer, SchemableIdentifierNode, TableNode, UsingNode } from 'kysely';
2
+ // Kysely's root-node guard is internal; keep this list checked against its public root union.
3
+ const ROOT_NODE_KINDS = {
4
+ AlterTableNode: true,
5
+ AlterTypeNode: true,
6
+ CreateIndexNode: true,
7
+ CreateSchemaNode: true,
8
+ CreateTableNode: true,
9
+ CreateTypeNode: true,
10
+ CreateViewNode: true,
11
+ DeleteQueryNode: true,
12
+ DropIndexNode: true,
13
+ DropSchemaNode: true,
14
+ DropTableNode: true,
15
+ DropTypeNode: true,
16
+ DropViewNode: true,
17
+ InsertQueryNode: true,
18
+ MergeQueryNode: true,
19
+ RawNode: true,
20
+ RefreshMaterializedViewNode: true,
21
+ SelectQueryNode: true,
22
+ UpdateQueryNode: true
23
+ };
24
+ function isRootOperationNode(node) {
25
+ return Object.hasOwn(ROOT_NODE_KINDS, node.kind);
26
+ }
27
+ class TablePrefixTransformer extends OperationNodeTransformer {
28
+ #prefix;
29
+ #tables = new Set();
30
+ #ctes = new Set();
31
+ #aliases = new Set();
32
+ #writeTargets = new Set();
33
+ #writeNames = new Set();
34
+ #prefixedIdentifiers = new WeakSet();
35
+ constructor(prefix){
36
+ super();
37
+ this.#prefix = prefix;
38
+ }
39
+ transformNodeImpl(node, queryId) {
40
+ if (!isRootOperationNode(node) || node.kind === 'RawNode' && this.nodeStack.length > 1) {
41
+ return super.transformNodeImpl(node, queryId);
42
+ }
43
+ return this.#transformScope(node, queryId);
44
+ }
45
+ #transformScope(node, queryId) {
46
+ const previousTables = this.#tables;
47
+ const previousCTEs = this.#ctes;
48
+ const previousAliases = this.#aliases;
49
+ const previousWriteTargets = this.#writeTargets;
50
+ const previousWriteNames = this.#writeNames;
51
+ this.#tables = new Set(previousTables);
52
+ this.#ctes = new Set(previousCTEs);
53
+ this.#aliases = new Set(previousAliases);
54
+ this.#writeTargets = new Set();
55
+ this.#writeNames = new Set(previousWriteNames);
56
+ try {
57
+ let withNode;
58
+ if ('with' in node && node.with) {
59
+ withNode = this.#transformWithScope(node.with, queryId);
60
+ }
61
+ this.#collectTables(node);
62
+ this.#collectWriteTargets(node);
63
+ const transformed = super.transformNodeImpl(withNode ? {
64
+ ...node,
65
+ with: undefined
66
+ } : node, queryId);
67
+ return withNode ? {
68
+ ...transformed,
69
+ with: withNode
70
+ } : transformed;
71
+ } finally{
72
+ // Nested queries inherit visible names without leaking them back into the outer query.
73
+ this.#tables = previousTables;
74
+ this.#ctes = previousCTEs;
75
+ this.#aliases = previousAliases;
76
+ this.#writeTargets = previousWriteTargets;
77
+ this.#writeNames = previousWriteNames;
78
+ }
79
+ }
80
+ transformTable(node, queryId) {
81
+ // Revisit plugged subqueries for correlated references, but keep their physical tables.
82
+ // Kysely transformers clone containers but retain immutable identifier leaves.
83
+ if (this.#prefixedIdentifiers.has(node.table.identifier)) return node;
84
+ const name = node.table.identifier.name;
85
+ if (this.#ctes.has(name) && !this.#writeTargets.has(node.table.identifier)) {
86
+ return super.transformTable(node, queryId);
87
+ }
88
+ return this.#prefixTable(node);
89
+ }
90
+ #prefixTable(node) {
91
+ if (node.table.schema !== undefined || this.#prefixedIdentifiers.has(node.table.identifier)) return node;
92
+ const name = node.table.identifier.name;
93
+ const transformed = TableNode.create(`${this.#prefix}_${name}`);
94
+ this.#prefixedIdentifiers.add(transformed.table.identifier);
95
+ return transformed;
96
+ }
97
+ transformReference(node, queryId) {
98
+ return {
99
+ ...node,
100
+ column: this.transformNode(node.column, queryId),
101
+ table: node.table && this.#tables.has(node.table.table.identifier.name) && !this.#aliases.has(node.table.table.identifier.name) ? this.#writeNames.has(node.table.table.identifier.name) ? this.#prefixTable(node.table) : this.transformNode(node.table, queryId) : node.table
102
+ };
103
+ }
104
+ transformSelectModifier(node, queryId) {
105
+ return {
106
+ ...super.transformSelectModifier({
107
+ ...node,
108
+ of: undefined
109
+ }, queryId),
110
+ of: node.of?.map((item)=>this.#transformTableReference(item, queryId))
111
+ };
112
+ }
113
+ transformAggregateFunction(node, queryId) {
114
+ return {
115
+ ...super.transformAggregateFunction({
116
+ ...node,
117
+ aggregated: []
118
+ }, queryId),
119
+ aggregated: this.#transformTableArgs(node.func, node.aggregated, queryId)
120
+ };
121
+ }
122
+ transformFunction(node, queryId) {
123
+ return {
124
+ ...super.transformFunction({
125
+ ...node,
126
+ arguments: []
127
+ }, queryId),
128
+ arguments: this.#transformTableArgs(node.func, node.arguments, queryId)
129
+ };
130
+ }
131
+ #transformTableArgs(func, args, queryId) {
132
+ return func === 'json_agg' || func === 'to_json' ? args.map((arg)=>this.#transformTableReference(arg, queryId)) : this.transformNodeList(args, queryId);
133
+ }
134
+ #transformTableReference(node, queryId) {
135
+ // Only direct table references can be aliases; raw sql.table() nodes still name physical tables.
136
+ return TableNode.is(node) && node.table.schema === undefined && this.#aliases.has(node.table.identifier.name) ? node : this.transformNode(node, queryId);
137
+ }
138
+ #collectTables(node) {
139
+ this.#collectRawTables(node);
140
+ if ('name' in node && node.name && SchemableIdentifierNode.is(node.name)) {
141
+ this.#tables.add(node.name.identifier.name);
142
+ }
143
+ if ('from' in node && node.from) {
144
+ for (const table of node.from.froms)this.#collectTableExpression(table);
145
+ }
146
+ if ('into' in node && node.into) this.#collectTableExpression(node.into);
147
+ if ('table' in node && node.table) this.#collectTableExpression(node.table);
148
+ if ('joins' in node && node.joins) {
149
+ for (const join of node.joins)this.#collectTableExpression(join.table);
150
+ }
151
+ if ('using' in node && node.using) {
152
+ this.#collectTableExpression(JoinNode.is(node.using) ? node.using.table : node.using);
153
+ }
154
+ }
155
+ #transformWithScope(node, queryId) {
156
+ // Nonrecursive definitions see preceding CTEs, not their own name or later names.
157
+ if (node.recursive) {
158
+ for (const expression of node.expressions){
159
+ this.#ctes.add(expression.name.table.table.identifier.name);
160
+ }
161
+ }
162
+ const expressions = [];
163
+ for (const expression of node.expressions){
164
+ expressions.push({
165
+ ...expression,
166
+ expression: expression.expression.kind === 'RawNode' ? Object.freeze(this.#transformScope(expression.expression, queryId)) : this.transformNode(expression.expression, queryId)
167
+ });
168
+ this.#ctes.add(expression.name.table.table.identifier.name);
169
+ }
170
+ return {
171
+ ...node,
172
+ expressions
173
+ };
174
+ }
175
+ #collectWriteTargets(node) {
176
+ const collect = (table)=>{
177
+ if (TableNode.is(table)) {
178
+ this.#writeTargets.add(table.table.identifier);
179
+ this.#writeNames.add(table.table.identifier.name);
180
+ } else if (AliasNode.is(table)) {
181
+ collect(table.node);
182
+ } else if (ListNode.is(table)) {
183
+ for (const item of table.items)collect(item);
184
+ }
185
+ };
186
+ if (node.kind === 'InsertQueryNode' || node.kind === 'MergeQueryNode') {
187
+ if (node.into) collect(node.into);
188
+ } else if (node.kind === 'UpdateQueryNode') {
189
+ if (node.table) collect(node.table);
190
+ } else if (node.kind === 'DeleteQueryNode') {
191
+ for (const table of node.from.froms)collect(table);
192
+ }
193
+ }
194
+ #collectRawTables(node) {
195
+ // Qualifiers are references, not declarations. Nested queries collect their own scope.
196
+ if (node.kind === 'ReferenceNode' || node.kind === 'WithNode') return;
197
+ if (node.kind === 'RawNode') {
198
+ for (const parameter of node.parameters){
199
+ if (AliasNode.is(parameter)) {
200
+ if (!isRootOperationNode(parameter.node) || parameter.node.kind === 'RawNode') this.#collectRawTables(parameter.node);
201
+ this.#collectTableExpression(parameter);
202
+ continue;
203
+ }
204
+ this.#collectTableExpression(parameter);
205
+ if (!isRootOperationNode(parameter) || parameter.kind === 'RawNode') this.#collectRawTables(parameter);
206
+ }
207
+ return;
208
+ }
209
+ for (const value of Object.values(node)){
210
+ for (const child of Array.isArray(value) ? value : [
211
+ value
212
+ ]){
213
+ if (child && typeof child === 'object' && 'kind' in child) {
214
+ const operation = child;
215
+ if (!isRootOperationNode(operation) || operation.kind === 'RawNode') this.#collectRawTables(operation);
216
+ }
217
+ }
218
+ }
219
+ }
220
+ #collectTableExpression(node) {
221
+ if (TableNode.is(node)) {
222
+ this.#tables.add(node.table.identifier.name);
223
+ this.#aliases.delete(node.table.identifier.name);
224
+ // Local read bindings shadow enclosing write qualifiers in correlated subqueries.
225
+ this.#writeNames.delete(node.table.identifier.name);
226
+ } else if (AliasNode.is(node)) {
227
+ if (TableNode.is(node.node)) this.#tables.add(node.node.table.identifier.name);
228
+ if (IdentifierNode.is(node.alias)) this.#aliases.add(node.alias.name);
229
+ } else if (ListNode.is(node)) {
230
+ for (const table of node.items)this.#collectTableExpression(table);
231
+ } else if (UsingNode.is(node)) {
232
+ for (const table of node.tables)this.#collectTableExpression(table);
233
+ }
234
+ }
235
+ }
236
+ export class TablePrefixPlugin {
237
+ #transformer;
238
+ constructor(prefix){
239
+ this.#transformer = new TablePrefixTransformer(prefix);
240
+ }
241
+ transformQuery({ node, queryId }) {
242
+ return this.#transformer.transformNode(node, queryId);
243
+ }
244
+ async transformResult({ result }) {
245
+ return result;
246
+ }
247
+ }
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@hozon/db",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Database registry, migrations and transactions for Hozon",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/TairuFramework/hozon.git",
8
+ "directory": "packages/db"
9
+ },
10
+ "license": "MIT",
11
+ "sideEffects": false,
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./lib/index.js"
15
+ },
16
+ "files": [
17
+ "lib/*"
18
+ ],
19
+ "dependencies": {
20
+ "@hozon/adapter": "^0.1.0",
21
+ "@sozai/log": "^0.4.0",
22
+ "kysely": "^0.29.6"
23
+ },
24
+ "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"
29
+ },
30
+ "engines": {
31
+ "node": ">=24"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
38
+ "build:clean": "del-cli lib",
39
+ "build:js": "del-cli \"lib/**/*.js\" && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
40
+ "build:types": "del-cli \"lib/**/*.d.ts\" \"lib/**/*.d.ts.map\" && tsc --emitDeclarationOnly --skipLibCheck",
41
+ "build:types:ci": "tsc --emitDeclarationOnly --skipLibCheck --declarationMap false",
42
+ "test": "pnpm run test:types && pnpm run test:unit",
43
+ "test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
44
+ "test:unit": "vitest run"
45
+ }
6
46
  }