@hozon/db 0.0.0-stage → 0.1.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,78 @@
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
+ kind: Adapter['kind'];
7
+ types: ColumnTypes;
8
+ functions: Functions;
9
+ };
10
+ export type StoreDefinition<Tables, API> = {
11
+ name: string;
12
+ migrations: Record<string, Migration> | ((ctx: MigrationContext) => Record<string, Migration>);
13
+ dependsOn?: Array<string>;
14
+ createAPI: (db: Kysely<Tables>, adapter: Adapter) => API;
15
+ };
16
+ export type StoreProvider<Stores extends Record<string, unknown> = Record<string, unknown>> = {
17
+ getStore<S extends keyof Stores & string>(name: S): Promise<Stores[S]>;
18
+ /**
19
+ * Returns whether a store definition is registered, synchronously and
20
+ * WITHOUT triggering migration. This distinguishes "plugin not installed"
21
+ * (registration absent) from "plugin installed but its store currently
22
+ * errors" — callers can fail closed on the latter instead of mistaking a
23
+ * transient store error for an uninstalled plugin.
24
+ */
25
+ hasStore(name: string): boolean;
26
+ onCommit(fn: () => void): void;
27
+ /**
28
+ * Register a hook to run after the transaction rolls back. By the time the
29
+ * hook fires the DB is already reverted, so it is the correct place to drop
30
+ * any in-memory state that was eagerly advanced in anticipation of a commit
31
+ * (e.g. cache invalidation). Like `onCommit`, hooks are isolated and their
32
+ * errors logged, never re-thrown. On the non-transactional provider it is a
33
+ * no-op (each standalone op auto-commits; nothing to roll back).
34
+ */
35
+ onRollback(fn: () => void): void;
36
+ /**
37
+ * Run `fn` against a `StoreProvider` that scopes its writes to a transaction.
38
+ *
39
+ * On `HozonDB`, opens a fresh DB transaction and provides a transactional
40
+ * `tx` provider; the outer provider's `onCommit` hooks fire after commit.
41
+ *
42
+ * On a transactional `tx` provider obtained from a parent
43
+ * `withTransaction` call, this method runs `fn` inline with `this` as the
44
+ * nested `tx`. The underlying driver does not nest transactions
45
+ * (no implicit savepoints), so atomicity is inherited from the enclosing
46
+ * transaction or explicit savepoint.
47
+ */
48
+ withTransaction<NestedStores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
49
+ /** Roll back only this unit of work when called inside a transaction. */
50
+ withSavepoint?<NestedStores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
51
+ };
52
+ export type HozonDBParams = {
53
+ adapter: Adapter;
54
+ logger?: Logger;
55
+ tablePrefix?: string;
56
+ };
57
+ export declare class HozonDB implements StoreProvider {
58
+ #private;
59
+ constructor(params: HozonDBParams);
60
+ get adapter(): Adapter;
61
+ register<Tables, API>(store: StoreDefinition<Tables, API>): void;
62
+ getStore<T>(name: string): Promise<T>;
63
+ hasStore(name: string): boolean;
64
+ onCommit(fn: () => void): void;
65
+ onRollback(_fn: () => void): void;
66
+ /**
67
+ * Runs `fn` inside a DB transaction and provides a scoped `StoreProvider`.
68
+ * Concurrent calls on the same `HozonDB` instance are supported — the
69
+ * underlying driver serializes them.
70
+ *
71
+ * Callers MUST use the provided `tx` inside the callback. Calling
72
+ * `db.withTransaction` on the same instance inside the callback opens a
73
+ * new independent transaction (no atomic nesting) and is a bug.
74
+ */
75
+ withTransaction<Stores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<Stores>) => Promise<R>): Promise<R>;
76
+ migrate(): Promise<void>;
77
+ close(): Promise<void>;
78
+ }
package/lib/db.js ADDED
@@ -0,0 +1,286 @@
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
+ export class HozonDB {
8
+ #tablePrefix;
9
+ #closed = false;
10
+ #closing = null;
11
+ #preflight = null;
12
+ #checked = new Set();
13
+ #migrations = new Map();
14
+ #adapter;
15
+ #db;
16
+ #logger;
17
+ #migrated = new Map();
18
+ #storeAPIs = new Map();
19
+ #stores = new Map();
20
+ constructor(params){
21
+ this.#tablePrefix = params.tablePrefix ?? 'hozon';
22
+ if (!/^[a-z][a-z0-9_]{0,30}$/.test(this.#tablePrefix)) {
23
+ throw new InvalidTablePrefixError(this.#tablePrefix);
24
+ }
25
+ this.#adapter = params.adapter;
26
+ this.#db = new Kysely({
27
+ // SQLite supports transactional DDL, but Kysely's SQLite adapters do not report it.
28
+ dialect: this.#adapter.kind === 'sqlite' ? withTransactionalDDL(this.#adapter.dialect) : this.#adapter.dialect,
29
+ plugins: [
30
+ new ParseJSONResultsPlugin()
31
+ ]
32
+ });
33
+ this.#logger = params.logger ?? getLogger([
34
+ 'hozon',
35
+ 'db'
36
+ ]);
37
+ }
38
+ get adapter() {
39
+ return this.#adapter;
40
+ }
41
+ register(store) {
42
+ const existing = this.#stores.get(store.name);
43
+ if (existing != null) {
44
+ if (existing !== store) {
45
+ this.#logger.warn('store "{name}" registered with different definition, ignoring', {
46
+ name: store.name
47
+ });
48
+ }
49
+ return;
50
+ }
51
+ this.#stores.set(store.name, store);
52
+ }
53
+ async getStore(name) {
54
+ this.#assertOpen();
55
+ await this.#ensurePreflight();
56
+ await this.#ensureMigrated(name);
57
+ this.#assertOpen();
58
+ if (!this.#storeAPIs.has(name)) {
59
+ const store = this.#stores.get(name);
60
+ if (store == null) {
61
+ throw new Error(`Store "${name}" is not registered`);
62
+ }
63
+ this.#storeAPIs.set(name, store.createAPI(this.#db, this.#adapter));
64
+ }
65
+ return this.#storeAPIs.get(name);
66
+ }
67
+ hasStore(name) {
68
+ return this.#stores.has(name);
69
+ }
70
+ onCommit(fn) {
71
+ fn();
72
+ }
73
+ // Each standalone op auto-commits, so there is nothing to roll back — no-op.
74
+ onRollback(_fn) {}
75
+ /**
76
+ * Runs `fn` inside a DB transaction and provides a scoped `StoreProvider`.
77
+ * Concurrent calls on the same `HozonDB` instance are supported — the
78
+ * underlying driver serializes them.
79
+ *
80
+ * Callers MUST use the provided `tx` inside the callback. Calling
81
+ * `db.withTransaction` on the same instance inside the callback opens a
82
+ * new independent transaction (no atomic nesting) and is a bug.
83
+ */ async withTransaction(fn) {
84
+ // Migrate every registered store before opening the transaction. Stores
85
+ // are internal and known up front, so a broken migration should fail fast
86
+ // at startup rather than leave a runtime partition where one store's
87
+ // failure silently blocks transactions that never touch it.
88
+ await this.migrate();
89
+ this.#assertOpen();
90
+ const commitHooks = [];
91
+ const rollbackHooks = [];
92
+ let result;
93
+ let savepointID = 0;
94
+ try {
95
+ result = await this.#db.transaction().execute(async (trx)=>{
96
+ const txKysely = trx;
97
+ const txStoreAPIs = new Map();
98
+ const createScopedProvider = (scopedCommitHooks, scopedRollbackHooks)=>{
99
+ // Savepoints on one connection form a stack: a scope holds at most
100
+ // one open savepoint, and nesting goes through that savepoint's
101
+ // own provider.
102
+ let savepointOpen = false;
103
+ const provider = {
104
+ getStore: async (name)=>{
105
+ if (!txStoreAPIs.has(name)) {
106
+ const store = this.#stores.get(name);
107
+ if (store == null) {
108
+ throw new Error(`Store "${name}" is not registered`);
109
+ }
110
+ txStoreAPIs.set(name, store.createAPI(txKysely, this.#adapter));
111
+ }
112
+ return txStoreAPIs.get(name);
113
+ },
114
+ hasStore: (name)=>this.#stores.has(name),
115
+ onCommit: (hook)=>{
116
+ scopedCommitHooks.push(hook);
117
+ },
118
+ onRollback: (hook)=>{
119
+ scopedRollbackHooks.push(hook);
120
+ },
121
+ withTransaction: async (nestedFn)=>{
122
+ // Already inside a transaction — Kysely does not nest.
123
+ // Run `nestedFn` inline with the same provider so its hooks and
124
+ // writes remain within the enclosing transaction or savepoint.
125
+ return await nestedFn(provider);
126
+ },
127
+ withSavepoint: async (nestedFn)=>{
128
+ if (savepointOpen) throw new SavepointOverlapError();
129
+ savepointOpen = true;
130
+ try {
131
+ const name = `${this.#tablePrefix}_sp_${++savepointID}`;
132
+ const savepointCommitHooks = [];
133
+ const savepointRollbackHooks = [];
134
+ const scopedProvider = createScopedProvider(savepointCommitHooks, savepointRollbackHooks);
135
+ await sql.raw(`SAVEPOINT ${name}`).execute(trx);
136
+ try {
137
+ const value = await nestedFn(scopedProvider);
138
+ await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
139
+ scopedCommitHooks.push(...savepointCommitHooks);
140
+ scopedRollbackHooks.push(...savepointRollbackHooks);
141
+ return value;
142
+ } catch (error) {
143
+ await sql.raw(`ROLLBACK TO SAVEPOINT ${name}`).execute(trx);
144
+ await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
145
+ this.#runHooks(savepointRollbackHooks, 'rollback');
146
+ throw error;
147
+ }
148
+ } finally{
149
+ savepointOpen = false;
150
+ }
151
+ }
152
+ };
153
+ return provider;
154
+ };
155
+ return await fn(createScopedProvider(commitHooks, rollbackHooks));
156
+ });
157
+ } catch (err) {
158
+ // Kysely issues ROLLBACK and rejects `execute()` when the callback
159
+ // throws, so the DB is already reverted here. Run the rollback hooks to
160
+ // drop any in-memory state that was eagerly advanced expecting a commit
161
+ // (revert first, then drop the stale cache). Hooks are isolated/logged,
162
+ // never re-thrown; the original error is preserved so callers (e.g. the
163
+ // engine's `MutateGraphWriteRollback` sentinel) still observe it.
164
+ this.#runHooks(rollbackHooks, 'rollback');
165
+ throw err;
166
+ }
167
+ // The transaction has committed; post-commit hooks are best-effort side
168
+ // effects (event emission, cache invalidation). A throwing hook must not
169
+ // skip the remaining hooks or make the caller observe a durable write as a
170
+ // failure, so each hook is isolated and its error logged, never re-thrown.
171
+ this.#runHooks(commitHooks, 'commit');
172
+ return result;
173
+ }
174
+ #runHooks(hooks, phase) {
175
+ for (const [index, hook] of hooks.entries()){
176
+ try {
177
+ hook();
178
+ } catch (error) {
179
+ this.#logger.error(`${phase} hook {index}/{count} failed`, {
180
+ index,
181
+ count: hooks.length,
182
+ error
183
+ });
184
+ }
185
+ }
186
+ }
187
+ async migrate() {
188
+ this.#assertOpen();
189
+ await this.#ensurePreflight();
190
+ await Promise.all(Array.from(this.#stores.keys()).map((name)=>this.#ensureMigrated(name)));
191
+ }
192
+ close() {
193
+ this.#closed = true;
194
+ this.#closing ??= this.#close();
195
+ return this.#closing;
196
+ }
197
+ async #close() {
198
+ try {
199
+ await this.#db.destroy();
200
+ } finally{
201
+ await this.#adapter.close?.();
202
+ }
203
+ this.#logger.info('closed');
204
+ }
205
+ #assertOpen() {
206
+ if (this.#closed) throw new HozonDBClosedError();
207
+ }
208
+ #getMigrations(store) {
209
+ let migrations = this.#migrations.get(store.name);
210
+ if (migrations == null) {
211
+ migrations = typeof store.migrations === 'function' ? store.migrations({
212
+ kind: this.#adapter.kind,
213
+ types: this.#adapter.types,
214
+ functions: this.#adapter.functions
215
+ }) : store.migrations;
216
+ this.#migrations.set(store.name, migrations);
217
+ }
218
+ return migrations;
219
+ }
220
+ #ensurePreflight() {
221
+ // Snapshot before awaiting so concurrent triggers share one read-only gate.
222
+ // A failed attempt clears the gate so the next caller reruns the read-only checks,
223
+ // then preparation; a successful attempt stays cached.
224
+ if (this.#preflight == null) {
225
+ const attempt = this.#runPreflight(Array.from(this.#stores.values())).catch((err)=>{
226
+ if (this.#preflight === attempt) this.#preflight = null;
227
+ throw err;
228
+ });
229
+ this.#preflight = attempt;
230
+ }
231
+ return this.#preflight;
232
+ }
233
+ async #runPreflight(stores) {
234
+ for (const store of stores){
235
+ await checkStore(this.#db, this.#tablePrefix, store.name, Object.keys(this.#getMigrations(store)));
236
+ this.#checked.add(store.name);
237
+ }
238
+ this.#assertOpen();
239
+ await this.#adapter.prepare?.();
240
+ }
241
+ async #ensureMigrated(name) {
242
+ let pending = this.#migrated.get(name);
243
+ if (pending == null) {
244
+ pending = this.#runStoreMigrations(name).catch((err)=>{
245
+ this.#migrated.delete(name);
246
+ throw err;
247
+ });
248
+ this.#migrated.set(name, pending);
249
+ }
250
+ return pending;
251
+ }
252
+ async #runStoreMigrations(name) {
253
+ const store = this.#stores.get(name);
254
+ if (store == null) {
255
+ throw new Error(`Store "${name}" is not registered`);
256
+ }
257
+ this.#assertOpen();
258
+ const migrations = this.#getMigrations(store);
259
+ if (!this.#checked.has(name)) {
260
+ await checkStore(this.#db, this.#tablePrefix, name, Object.keys(migrations));
261
+ this.#checked.add(name);
262
+ }
263
+ for (const dep of store.dependsOn ?? []){
264
+ await this.#ensureMigrated(dep);
265
+ }
266
+ this.#assertOpen();
267
+ if (Object.keys(migrations).length === 0) {
268
+ return;
269
+ }
270
+ const migrator = new Migrator({
271
+ db: this.#db,
272
+ provider: {
273
+ getMigrations: ()=>Promise.resolve(migrations)
274
+ },
275
+ migrationTableName: `${this.#tablePrefix}_${name}_migration`,
276
+ migrationLockTableName: `${this.#tablePrefix}_${name}_migration_lock`
277
+ });
278
+ const result = await migrator.migrateToLatest();
279
+ if (result.error != null) {
280
+ throw result.error;
281
+ }
282
+ this.#logger.info('store {name} migrations complete', {
283
+ name
284
+ });
285
+ }
286
+ }
@@ -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,5 @@
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';
package/lib/index.js ADDED
@@ -0,0 +1,4 @@
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';
@@ -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
+ }
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.1.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
  }