@hozon/db 0.3.0 → 0.5.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/lib/db.d.ts CHANGED
@@ -3,16 +3,31 @@ import { type Logger } from '@sozai/log';
3
3
  import { Kysely } from 'kysely';
4
4
  import { type Migration } from 'kysely/migration';
5
5
  import type { BatchLoader, BatchLoaderParams } from './batch-loader.js';
6
+ import { type CompareDefinition } from './sync.js';
6
7
  export type MigrationContext = {
7
8
  tablePrefix: string;
8
9
  kind: Adapter['kind'];
9
10
  types: ColumnTypes;
10
11
  functions: Functions;
11
12
  };
12
- export type StoreContext = {
13
+ export type BaseStoreContext = {
14
+ compareWrite<T extends string>(table: T, row: Record<string, unknown>): Promise<{
15
+ changed: boolean;
16
+ }>;
13
17
  createLoader<TKey, TValue>(params: BatchLoaderParams<TKey, TValue>): BatchLoader<TKey, TValue>;
18
+ provider: StoreProvider;
19
+ root: StoreProvider;
20
+ onClose(fn: () => Promise<void> | void): void;
14
21
  };
15
- export type StoreDefinition<Tables, API> = {
22
+ type ContextExtension = object & {
23
+ [Key in keyof BaseStoreContext]?: never;
24
+ };
25
+ export type StoreContext<Ext extends ContextExtension = {}> = BaseStoreContext & Ext;
26
+ export type ContextFactory<Ext extends ContextExtension> = (scope: {
27
+ provider: StoreProvider;
28
+ root: StoreProvider;
29
+ }) => keyof Ext & keyof BaseStoreContext extends never ? Ext : never;
30
+ export type StoreDefinition<Tables, API, Ext extends ContextExtension = {}> = {
16
31
  name: string;
17
32
  migrations: Record<string, Migration> | ((ctx: MigrationContext) => Record<string, Migration>);
18
33
  dependsOn?: Array<string>;
@@ -22,9 +37,15 @@ export type StoreDefinition<Tables, API> = {
22
37
  * tables are not namespaced: the store must keep them collision-free.
23
38
  */
24
39
  unprefixedTables?: (name: string) => boolean;
25
- createAPI: (db: Kysely<Tables>, adapter: Adapter, context: StoreContext) => API;
40
+ sync?: {
41
+ [T in keyof Tables & string]?: CompareDefinition<Tables[T]>;
42
+ };
43
+ createAPI: (db: Kysely<Tables>, adapter: Adapter, context: StoreContext<Ext>) => API;
26
44
  };
27
45
  export type StoreProvider<Stores extends Record<string, unknown> = Record<string, unknown>> = {
46
+ readonly isTransactionBound: boolean;
47
+ /** Identity of the outermost transaction provider, shared by savepoints. Absent outside transactions. */
48
+ readonly transactionRoot?: StoreProvider;
28
49
  getStore<S extends keyof Stores & string>(name: S): Promise<Stores[S]>;
29
50
  /**
30
51
  * Returns whether a store definition is registered, synchronously and
@@ -61,16 +82,22 @@ export type StoreProvider<Stores extends Record<string, unknown> = Record<string
61
82
  /** Roll back only this unit of work when called inside a transaction. */
62
83
  withSavepoint?<NestedStores extends Record<string, unknown>, R>(fn: (tx: StoreProvider<NestedStores>) => Promise<R>): Promise<R>;
63
84
  };
64
- export type HozonDBParams = {
85
+ export type HozonDBParams<Ext extends ContextExtension = {}> = {
65
86
  adapter: Adapter;
66
87
  logger?: Logger;
67
88
  tablePrefix?: string;
68
- };
69
- export declare class HozonDB implements StoreProvider {
89
+ } & (keyof Ext extends never ? {
90
+ context?: ContextFactory<Ext>;
91
+ } : {
92
+ context: ContextFactory<Ext>;
93
+ });
94
+ export declare class HozonDB<Ext extends ContextExtension = {}> implements StoreProvider {
70
95
  #private;
71
- constructor(params: HozonDBParams);
96
+ constructor(params: HozonDBParams<Ext>);
72
97
  get adapter(): Adapter;
73
- register<Tables, API>(store: StoreDefinition<Tables, API>): void;
98
+ get isTransactionBound(): boolean;
99
+ get transactionRoot(): StoreProvider | undefined;
100
+ register<Tables, API>(store: StoreDefinition<Tables, API, Ext>): void;
74
101
  getStore<T>(name: string): Promise<T>;
75
102
  hasStore(name: string): boolean;
76
103
  onCommit(fn: () => void): void;
@@ -89,3 +116,4 @@ export declare class HozonDB implements StoreProvider {
89
116
  migrate(): Promise<void>;
90
117
  close(): Promise<void>;
91
118
  }
119
+ export {};
package/lib/db.js CHANGED
@@ -2,11 +2,15 @@ import { getLogger } from '@sozai/log';
2
2
  import { Kysely, sql } from 'kysely';
3
3
  import { Migrator } from 'kysely/migration';
4
4
  import { withTransactionalDDL } from './dialect.js';
5
- import { HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError } from './errors.js';
5
+ import { HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SavepointReleasedError, StoreAlreadyRegisteredError } from './errors.js';
6
6
  import { LoaderRegistry } from './loader-registry.js';
7
- import { checkStore } from './preflight.js';
7
+ import { checkStore, readAppliedMigrations } from './preflight.js';
8
+ import { bindSyncProvider, compareWrite, settleSyncProvider } from './sync.js';
8
9
  import { TablePrefixPlugin } from './table-prefix.js';
10
+ // biome-ignore lint/complexity/noBannedTypes: The default adds no context fields.
9
11
  export class HozonDB {
12
+ #context;
13
+ #closeHooks = [];
10
14
  #tablePrefix;
11
15
  #defaultPlugin;
12
16
  #closed = false;
@@ -26,6 +30,7 @@ export class HozonDB {
26
30
  #storeAPIs = new Map();
27
31
  #stores = new Map();
28
32
  constructor(params){
33
+ this.#context = params.context;
29
34
  this.#tablePrefix = params.tablePrefix ?? 'hozon';
30
35
  if (!/^[a-z][a-z0-9_]{0,30}$/.test(this.#tablePrefix)) {
31
36
  throw new InvalidTablePrefixError(this.#tablePrefix);
@@ -45,13 +50,17 @@ export class HozonDB {
45
50
  get adapter() {
46
51
  return this.#adapter;
47
52
  }
53
+ get isTransactionBound() {
54
+ return false;
55
+ }
56
+ get transactionRoot() {
57
+ return undefined;
58
+ }
48
59
  register(store) {
49
60
  const existing = this.#stores.get(store.name);
50
61
  if (existing != null) {
51
62
  if (existing !== store) {
52
- this.#logger.warn('store "{name}" registered with different definition, ignoring', {
53
- name: store.name
54
- });
63
+ throw new StoreAlreadyRegisteredError(store.name);
55
64
  }
56
65
  return;
57
66
  }
@@ -67,7 +76,7 @@ export class HozonDB {
67
76
  if (store == null) {
68
77
  throw new Error(`Store "${name}" is not registered`);
69
78
  }
70
- this.#storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, this.#loaders));
79
+ this.#storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, this.#loaders, this));
71
80
  }
72
81
  return this.#storeAPIs.get(name);
73
82
  }
@@ -86,6 +95,7 @@ export class HozonDB {
86
95
  });
87
96
  const storeAPIs = new Map();
88
97
  const provider = {
98
+ isTransactionBound: false,
89
99
  getStore: async (name)=>{
90
100
  this.#assertOpen();
91
101
  await this.#ensurePreflight();
@@ -94,7 +104,7 @@ export class HozonDB {
94
104
  if (!storeAPIs.has(name)) {
95
105
  const store = this.#stores.get(name);
96
106
  if (store == null) throw new Error(`Store "${name}" is not registered`);
97
- storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, registry));
107
+ storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, registry, provider));
98
108
  }
99
109
  return storeAPIs.get(name);
100
110
  },
@@ -144,70 +154,114 @@ export class HozonDB {
144
154
  result = await this.#db.transaction().execute(async (trx)=>{
145
155
  const txKysely = trx;
146
156
  const txStoreDB = txKysely.withPlugin(this.#defaultPlugin);
147
- const txStoreAPIs = new Map();
148
- const createScopedProvider = (scopedCommitHooks, scopedRollbackHooks)=>{
157
+ const createScopedProvider = (scopedCommitHooks, scopedRollbackHooks, transactionRoot, syncParent, syncSavepoint = false, scopeState = {
158
+ savepointOpen: false,
159
+ settled: false,
160
+ savepointBound: syncSavepoint
161
+ })=>{
149
162
  // Savepoints on one connection form a stack: a scope holds at most
150
163
  // one open savepoint, and nesting goes through that savepoint's
151
164
  // own provider.
152
- let savepointOpen = false;
165
+ const assertActive = ()=>{
166
+ if (scopeState.settled) throw new SavepointReleasedError();
167
+ };
168
+ const txStoreAPIs = new Map();
153
169
  const provider = {
170
+ isTransactionBound: true,
171
+ get transactionRoot () {
172
+ return transactionRoot ?? provider;
173
+ },
154
174
  getStore: async (name)=>{
175
+ assertActive();
176
+ this.#assertOpen();
155
177
  if (!txStoreAPIs.has(name)) {
156
178
  const store = this.#stores.get(name);
157
179
  if (store == null) {
158
180
  throw new Error(`Store "${name}" is not registered`);
159
181
  }
160
- txStoreAPIs.set(name, this.#createStoreAPI(store, txKysely, txStoreDB, registry));
182
+ txStoreAPIs.set(name, this.#createStoreAPI(store, txKysely, txStoreDB, registry, provider, scopeState.savepointBound ? assertActive : undefined));
161
183
  }
162
184
  return txStoreAPIs.get(name);
163
185
  },
164
- hasStore: (name)=>this.#stores.has(name),
186
+ hasStore: (name)=>{
187
+ assertActive();
188
+ return this.#stores.has(name);
189
+ },
165
190
  onCommit: (hook)=>{
191
+ assertActive();
166
192
  scopedCommitHooks.push(hook);
167
193
  },
168
194
  onRollback: (hook)=>{
195
+ assertActive();
169
196
  scopedRollbackHooks.push(hook);
170
197
  },
171
198
  withScope: async (nestedFn)=>{
199
+ assertActive();
172
200
  return await nestedFn(provider);
173
201
  },
174
202
  withTransaction: async (nestedFn)=>{
175
203
  // Already inside a transaction — Kysely does not nest.
176
204
  // Run `nestedFn` inline with the same provider so its hooks and
177
205
  // writes remain within the enclosing transaction or savepoint.
206
+ assertActive();
178
207
  return await nestedFn(provider);
179
208
  },
180
209
  withSavepoint: async (nestedFn)=>{
181
- if (savepointOpen) throw new SavepointOverlapError();
182
- savepointOpen = true;
210
+ assertActive();
211
+ if (scopeState.savepointOpen) throw new SavepointOverlapError();
212
+ scopeState.savepointOpen = true;
183
213
  try {
184
214
  const name = `${this.#tablePrefix}_sp_${++savepointID}`;
185
215
  const savepointCommitHooks = [];
186
216
  const savepointRollbackHooks = [];
187
- const scopedProvider = createScopedProvider(savepointCommitHooks, savepointRollbackHooks);
217
+ const savepointState = {
218
+ savepointOpen: false,
219
+ settled: false,
220
+ savepointBound: true
221
+ };
222
+ const scopedProvider = createScopedProvider(savepointCommitHooks, savepointRollbackHooks, provider.transactionRoot, provider, true, savepointState);
188
223
  registry.dispatchAll();
189
224
  await sql.raw(`SAVEPOINT ${name}`).execute(trx);
190
225
  try {
191
226
  const value = await nestedFn(scopedProvider);
192
227
  registry.dispatchAll();
193
228
  await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
194
- scopedCommitHooks.push(...savepointCommitHooks);
195
- scopedRollbackHooks.push(...savepointRollbackHooks);
229
+ savepointState.settled = true;
230
+ registry.clearAll();
231
+ for (const hook of savepointCommitHooks)provider.onCommit(hook);
232
+ for (const hook of savepointRollbackHooks)provider.onRollback(hook);
196
233
  return value;
197
234
  } catch (error) {
198
235
  registry.dispatchAll();
199
236
  await sql.raw(`ROLLBACK TO SAVEPOINT ${name}`).execute(trx);
200
237
  registry.dispatchAll();
201
238
  await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
239
+ savepointState.settled = true;
202
240
  registry.clearAll();
203
241
  this.#runHooks(savepointRollbackHooks, 'rollback');
204
242
  throw error;
243
+ } finally{
244
+ savepointState.settled = true;
245
+ settleSyncProvider(scopedProvider);
205
246
  }
206
247
  } finally{
207
- savepointOpen = false;
248
+ scopeState.savepointOpen = false;
208
249
  }
209
250
  }
210
251
  };
252
+ bindSyncProvider(provider, {
253
+ assertActive,
254
+ derive: ()=>createScopedProvider(scopedCommitHooks, scopedRollbackHooks, provider.transactionRoot, provider, false, scopeState),
255
+ db: txKysely,
256
+ kind: this.#adapter.kind,
257
+ tablePrefix: this.#tablePrefix,
258
+ tableName: (table)=>{
259
+ const unprefixed = [
260
+ ...this.#stores.values()
261
+ ].some((store)=>store.unprefixedTables?.(table));
262
+ return unprefixed ? table : `${this.#tablePrefix}_${table}`;
263
+ }
264
+ }, syncParent, syncSavepoint);
211
265
  return provider;
212
266
  };
213
267
  try {
@@ -260,6 +314,17 @@ export class HozonDB {
260
314
  return this.#closing;
261
315
  }
262
316
  async #close() {
317
+ // Publish #closing before a hook can re-enter close().
318
+ await Promise.resolve();
319
+ for (const hook of this.#closeHooks){
320
+ try {
321
+ await hook();
322
+ } catch (error) {
323
+ this.#logger.error('close hook failed', {
324
+ error
325
+ });
326
+ }
327
+ }
263
328
  try {
264
329
  await this.#db.destroy();
265
330
  } finally{
@@ -294,13 +359,31 @@ export class HozonDB {
294
359
  }
295
360
  return migrations;
296
361
  }
297
- // Hands stores only createLoader, so they cannot reach close/dispatchAll/clearAll.
298
362
  // Stores on the default plugin must share one Kysely instance: keep-set locks key on it.
299
- #createStoreAPI(store, kysely, defaultDB, registry) {
363
+ #createStoreAPI(store, kysely, defaultDB, registry, provider, assertActive) {
300
364
  const storeDB = store.unprefixedTables == null ? defaultDB : kysely.withPlugin(this.#getStorePlugin(store));
301
- return store.createAPI(storeDB, this.#adapter, {
302
- createLoader: (params)=>registry.createLoader(params)
365
+ const base = {
366
+ compareWrite: async (table, row)=>{
367
+ assertActive?.();
368
+ return await compareWrite(storeDB, table, row, store.sync?.[table]);
369
+ },
370
+ createLoader: (params)=>{
371
+ assertActive?.();
372
+ return registry.createLoader(params);
373
+ },
374
+ provider,
375
+ root: this,
376
+ onClose: (fn)=>{
377
+ this.#assertOpen();
378
+ this.#closeHooks.push(fn);
379
+ }
380
+ };
381
+ const ext = this.#context?.({
382
+ provider,
383
+ root: this
303
384
  });
385
+ const context = Object.defineProperties(base, Object.getOwnPropertyDescriptors(ext ?? {}));
386
+ return store.createAPI(storeDB, this.#adapter, context);
304
387
  }
305
388
  #getStorePlugin(store) {
306
389
  if (store.unprefixedTables == null) return this.#defaultPlugin;
@@ -363,6 +446,15 @@ export class HozonDB {
363
446
  if (Object.keys(migrations).length === 0) {
364
447
  return;
365
448
  }
449
+ // Kysely's migrator opens a transaction even when nothing is pending, which fails while
450
+ // another database instance holds the same single SQLite connection in a transaction.
451
+ const applied = await readAppliedMigrations(this.#db, this.#tablePrefix, name);
452
+ if (applied != null && Object.keys(migrations).every((key)=>applied.has(key))) {
453
+ this.#logger.debug('store {name} migrations already applied', {
454
+ name
455
+ });
456
+ return;
457
+ }
366
458
  const migrator = new Migrator({
367
459
  db: this.#db,
368
460
  provider: {
package/lib/errors.d.ts CHANGED
@@ -1,3 +1,9 @@
1
+ export declare class SavepointReleasedError extends Error {
2
+ constructor();
3
+ }
4
+ export declare class KeyLockScopeError extends Error {
5
+ constructor();
6
+ }
1
7
  export declare class SavepointOverlapError extends Error {
2
8
  constructor();
3
9
  }
@@ -10,6 +16,9 @@ export declare class InvalidTablePrefixError extends Error {
10
16
  export declare class HozonDBClosedError extends Error {
11
17
  constructor();
12
18
  }
19
+ export declare class StoreAlreadyRegisteredError extends Error {
20
+ constructor(store: string);
21
+ }
13
22
  export declare class BatchLimitError extends Error {
14
23
  constructor(size: number, limit: number);
15
24
  }
package/lib/errors.js CHANGED
@@ -1,3 +1,15 @@
1
+ export class SavepointReleasedError extends Error {
2
+ constructor(){
3
+ super('The savepoint has already been released or rolled back');
4
+ this.name = 'SavepointReleasedError';
5
+ }
6
+ }
7
+ export class KeyLockScopeError extends Error {
8
+ constructor(){
9
+ super('The key is reserved by an unsettled savepoint outside this provider scope');
10
+ this.name = 'KeyLockScopeError';
11
+ }
12
+ }
1
13
  export class SavepointOverlapError extends Error {
2
14
  constructor(){
3
15
  super('A savepoint is already open in this transaction scope');
@@ -22,6 +34,12 @@ export class HozonDBClosedError extends Error {
22
34
  this.name = 'HozonDBClosedError';
23
35
  }
24
36
  }
37
+ export class StoreAlreadyRegisteredError extends Error {
38
+ constructor(store){
39
+ super(`Store "${store}" is already registered with a different definition`);
40
+ this.name = 'StoreAlreadyRegisteredError';
41
+ }
42
+ }
25
43
  export class BatchLimitError extends Error {
26
44
  constructor(size, limit){
27
45
  super(`Batch of ${size} keys exceeds limit ${limit}`);
package/lib/index.d.ts CHANGED
@@ -3,9 +3,10 @@ export { Kysely, sql } from 'kysely';
3
3
  export type { Migration } from 'kysely/migration';
4
4
  export { assertBatchSize, type BatchReadOptions, type IterateBatchesOptions, iterateBatches, } from './batch.js';
5
5
  export { type BatchLoader, type BatchLoaderParams, createBatchLoader, } from './batch-loader.js';
6
- export type { HozonDBParams, MigrationContext, StoreContext, StoreDefinition, StoreProvider, } from './db.js';
6
+ export type { BaseStoreContext, ContextFactory, HozonDBParams, MigrationContext, StoreContext, StoreDefinition, StoreProvider, } from './db.js';
7
7
  export { HozonDB } from './db.js';
8
- export { BatchLimitError, HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SchemaVersionError, } from './errors.js';
9
- export { chunk, withKeepSet } from './keep-set.js';
8
+ export { BatchLimitError, HozonDBClosedError, InvalidTablePrefixError, KeyLockScopeError, SavepointOverlapError, SavepointReleasedError, SchemaVersionError, StoreAlreadyRegisteredError, } from './errors.js';
9
+ export { chunk, IN_LIST_CHUNK, withKeepSet } from './keep-set.js';
10
10
  export { withStoreTransaction } from './store-transaction.js';
11
+ export { type CompareDefinition, compareText, withKeyLock, withKeyLocks } from './sync.js';
11
12
  export { type TablePrefixOptions, TablePrefixPlugin } from './table-prefix.js';
package/lib/index.js CHANGED
@@ -2,7 +2,8 @@ export { Kysely, sql } from 'kysely';
2
2
  export { assertBatchSize, iterateBatches } from './batch.js';
3
3
  export { createBatchLoader } from './batch-loader.js';
4
4
  export { HozonDB } from './db.js';
5
- export { BatchLimitError, HozonDBClosedError, InvalidTablePrefixError, SavepointOverlapError, SchemaVersionError } from './errors.js';
6
- export { chunk, withKeepSet } from './keep-set.js';
5
+ export { BatchLimitError, HozonDBClosedError, InvalidTablePrefixError, KeyLockScopeError, SavepointOverlapError, SavepointReleasedError, SchemaVersionError, StoreAlreadyRegisteredError } from './errors.js';
6
+ export { chunk, IN_LIST_CHUNK, withKeepSet } from './keep-set.js';
7
7
  export { withStoreTransaction } from './store-transaction.js';
8
+ export { compareText, withKeyLock, withKeyLocks } from './sync.js';
8
9
  export { TablePrefixPlugin } from './table-prefix.js';
package/lib/keep-set.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { Kysely, SelectQueryBuilder } from 'kysely';
2
- export declare function chunk<T>(items: Array<T>, size?: number): Array<Array<T>>;
2
+ export declare const IN_LIST_CHUNK = 500;
3
+ export declare function chunk<T>(items: Iterable<T>, size?: number, options?: {
4
+ unique?: boolean;
5
+ }): Array<Array<T>>;
3
6
  type KeepTable = {
4
7
  keep: {
5
8
  trace_id: string;
package/lib/keep-set.js CHANGED
@@ -1,9 +1,15 @@
1
1
  import { sql } from 'kysely';
2
- export function chunk(items, size = 500) {
2
+ export const IN_LIST_CHUNK = 500;
3
+ export function chunk(items, size = IN_LIST_CHUNK, options) {
3
4
  if (!Number.isInteger(size) || size <= 0) throw new RangeError('Chunk size must be a positive integer');
4
5
  const chunks = [];
5
- for(let offset = 0; offset < items.length; offset += size){
6
- chunks.push(items.slice(offset, offset + size));
6
+ const values = options?.unique ? [
7
+ ...new Set(items)
8
+ ] : [
9
+ ...items
10
+ ];
11
+ for(let offset = 0; offset < values.length; offset += size){
12
+ chunks.push(values.slice(offset, offset + size));
7
13
  }
8
14
  return chunks;
9
15
  }
@@ -1,2 +1,4 @@
1
1
  import type { Kysely } from 'kysely';
2
+ /** Names of the migrations recorded for a store, or `null` when its migration table is absent. */
3
+ export declare function readAppliedMigrations<DB>(db: Kysely<DB>, prefix: string, name: string): Promise<Set<string> | null>;
2
4
  export declare function checkStore<DB>(db: Kysely<DB>, prefix: string, name: string, keys: Array<string>): Promise<void>;
package/lib/preflight.js CHANGED
@@ -1,18 +1,23 @@
1
1
  import { sql } from 'kysely';
2
2
  import { SchemaVersionError } from './errors.js';
3
- export async function checkStore(db, prefix, name, keys) {
4
- let rows;
3
+ /** Names of the migrations recorded for a store, or `null` when its migration table is absent. */ export async function readAppliedMigrations(db, prefix, name) {
5
4
  try {
6
5
  const result = await sql`SELECT name FROM ${sql.table(`${prefix}_${name}_migration`)}`.execute(db);
7
- rows = result.rows;
6
+ return new Set(result.rows.map((row)=>row.name));
8
7
  } catch (error) {
9
8
  // Only a missing table is a fresh schema. Connection and permission errors must surface.
10
9
  if (error instanceof Error && ('code' in error && error.code === '42P01' || /no such table:/i.test(error.message))) {
11
- return;
10
+ return null;
12
11
  }
13
12
  throw error;
14
13
  }
14
+ }
15
+ export async function checkStore(db, prefix, name, keys) {
16
+ const applied = await readAppliedMigrations(db, prefix, name);
17
+ if (applied == null) return;
15
18
  const known = new Set(keys);
16
- const unknown = rows.map((row)=>row.name).filter((key)=>!known.has(key)).sort();
19
+ const unknown = [
20
+ ...applied
21
+ ].filter((key)=>!known.has(key)).sort();
17
22
  if (unknown.length > 0) throw new SchemaVersionError(name, unknown);
18
23
  }
package/lib/sync.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { Adapter } from '@hozon/adapter';
2
+ import type { ColumnDefinitionBuilder, Kysely } from 'kysely';
3
+ import type { MigrationContext, StoreProvider } from './db.js';
4
+ export type CompareDefinition<Row> = {
5
+ key: Array<keyof Row & string>;
6
+ compare: Array<keyof Row & string>;
7
+ inclusive?: boolean;
8
+ nullsOldest?: boolean;
9
+ skipUnchanged?: boolean;
10
+ cursor?: never;
11
+ tombstones?: never;
12
+ };
13
+ type LockContext = {
14
+ assertActive: () => void;
15
+ derive: () => StoreProvider;
16
+ db: Kysely<Record<string, unknown>>;
17
+ kind: Adapter['kind'];
18
+ tablePrefix: string;
19
+ tableName: (table: string) => string;
20
+ };
21
+ export declare function settleSyncProvider(provider: StoreProvider): void;
22
+ export declare function bindSyncProvider(provider: StoreProvider, context: LockContext, parent?: StoreProvider, savepoint?: boolean): void;
23
+ export declare function withKeyLock<R>(provider: StoreProvider, table: string, key: ReadonlyArray<string | number>, fn: (tx: StoreProvider) => Promise<R>): Promise<R>;
24
+ export declare function withKeyLocks<R>(provider: StoreProvider, table: string, keys: ReadonlyArray<ReadonlyArray<string | number>>, fn: (tx: StoreProvider) => Promise<R>): Promise<R>;
25
+ export declare function compareText(ctx: MigrationContext): (column: ColumnDefinitionBuilder) => ColumnDefinitionBuilder;
26
+ export declare function compareWrite(db: Kysely<Record<string, unknown>>, table: string, row: Record<string, unknown>, definition?: CompareDefinition<Record<string, unknown>>): Promise<{
27
+ changed: boolean;
28
+ }>;
29
+ export {};
package/lib/sync.js ADDED
@@ -0,0 +1,188 @@
1
+ import { sql } from 'kysely';
2
+ import { KeyLockScopeError } from './errors.js';
3
+ const providerScopes = new WeakMap();
4
+ const savepointScopes = new WeakMap();
5
+ export function settleSyncProvider(provider) {
6
+ const scope = providerScopes.get(provider);
7
+ if (scope) savepointScopes.get(provider.transactionRoot ?? provider)?.delete(scope);
8
+ }
9
+ const lockContexts = new WeakMap();
10
+ const ownership = new WeakMap();
11
+ const keyLocks = new WeakMap();
12
+ export function bindSyncProvider(provider, context, parent, savepoint = false) {
13
+ lockContexts.set(provider, context);
14
+ ownership.set(provider, new Map(parent == null ? [] : ownership.get(parent)));
15
+ const parentScope = parent == null ? undefined : providerScopes.get(parent);
16
+ if (savepoint) {
17
+ const scope = {
18
+ parent: parentScope,
19
+ keys: new Set()
20
+ };
21
+ providerScopes.set(provider, scope);
22
+ const root = provider.transactionRoot ?? provider;
23
+ let scopes = savepointScopes.get(root);
24
+ if (!scopes) {
25
+ scopes = new Set();
26
+ savepointScopes.set(root, scopes);
27
+ }
28
+ scopes.add(scope);
29
+ } else if (parentScope) {
30
+ providerScopes.set(provider, parentScope);
31
+ }
32
+ }
33
+ async function withQueue(root, key, fn) {
34
+ let locks = keyLocks.get(root);
35
+ if (locks == null) {
36
+ locks = new Map();
37
+ keyLocks.set(root, locks);
38
+ }
39
+ const previous = locks.get(key) ?? Promise.resolve();
40
+ let release;
41
+ const current = new Promise((resolve)=>{
42
+ release = resolve;
43
+ });
44
+ const tail = previous.then(()=>current);
45
+ locks.set(key, tail);
46
+ await previous;
47
+ try {
48
+ return await fn();
49
+ } finally{
50
+ release();
51
+ if (locks.get(key) === tail) locks.delete(key);
52
+ }
53
+ }
54
+ export async function withKeyLock(provider, table, key, fn) {
55
+ return await withKeyLocks(provider, table, [
56
+ key
57
+ ], fn);
58
+ }
59
+ export async function withKeyLocks(provider, table, keys, fn) {
60
+ if (!provider.isTransactionBound) {
61
+ return await provider.withTransaction((tx)=>withKeyLocks(tx, table, keys, fn));
62
+ }
63
+ const root = provider.transactionRoot ?? provider;
64
+ const context = lockContexts.get(provider);
65
+ if (context == null) throw new Error('withKeyLock requires a HozonDB provider');
66
+ context.assertActive();
67
+ const identities = [
68
+ ...new Set(keys.map((key)=>{
69
+ if (key.some((part)=>typeof part === 'number' && !Number.isFinite(part))) {
70
+ throw new Error('Lock keys require finite numbers');
71
+ }
72
+ return JSON.stringify([
73
+ context.tablePrefix,
74
+ table,
75
+ key
76
+ ]);
77
+ }))
78
+ ];
79
+ let lockIDs;
80
+ if (context.kind === 'postgres') {
81
+ const ids = [];
82
+ for (const identity of identities){
83
+ const result = await sql`SELECT (('x' || substr(md5(${identity}::text), 1, 16))::bit(64)::bigint)::text AS id`.execute(context.db);
84
+ for (const row of result.rows)ids.push(BigInt(row.id));
85
+ }
86
+ // Order and deduplicate the actual IDs: collisions only add contention.
87
+ lockIDs = [
88
+ ...new Set(ids)
89
+ ].sort((a, b)=>a < b ? -1 : a > b ? 1 : 0).map(String);
90
+ } else {
91
+ lockIDs = identities.sort();
92
+ }
93
+ const scope = providerScopes.get(provider);
94
+ const ancestors = new Set();
95
+ for(let current = scope; current; current = current.parent)ancestors.add(current);
96
+ const checkScope = (id)=>{
97
+ for (const reservation of savepointScopes.get(root) ?? []){
98
+ if (reservation.keys.has(id) && !ancestors.has(reservation)) throw new KeyLockScopeError();
99
+ }
100
+ };
101
+ for (const id of lockIDs)checkScope(id);
102
+ const held = ownership.get(provider);
103
+ const acquired = {
104
+ active: true
105
+ };
106
+ const locked = context.derive();
107
+ // biome-ignore lint/style/noNonNullAssertion: derive binds ownership before returning.
108
+ const owned = ownership.get(locked);
109
+ const acquire = async (index)=>{
110
+ const id = lockIDs[index];
111
+ if (id === undefined) {
112
+ if (context.kind === 'sqlite' && lockIDs.length > 0) {
113
+ // Even a zero-row DELETE reserves SQLite's writer before the callback reads.
114
+ await sql`DELETE FROM ${sql.table(context.tableName(table))} WHERE 1 = 0`.execute(context.db);
115
+ }
116
+ for (const id of lockIDs){
117
+ checkScope(id);
118
+ for (const ancestor of ancestors)ancestor.keys.add(id);
119
+ }
120
+ context.assertActive();
121
+ return await fn(locked);
122
+ }
123
+ checkScope(id);
124
+ if (held?.get(id)?.active) return await acquire(index + 1);
125
+ return await withQueue(root, id, async ()=>{
126
+ checkScope(id);
127
+ if (context.kind === 'postgres') {
128
+ await sql`SELECT pg_advisory_xact_lock(${id}::bigint)`.execute(context.db);
129
+ }
130
+ for (const ancestor of ancestors)ancestor.keys.add(id);
131
+ owned.set(id, acquired);
132
+ return await acquire(index + 1);
133
+ });
134
+ };
135
+ try {
136
+ return await acquire(0);
137
+ } finally{
138
+ acquired.active = false;
139
+ }
140
+ }
141
+ export function compareText(ctx) {
142
+ return (column)=>ctx.kind === 'postgres' ? column.modifyEnd(sql`collate "C"`) : column;
143
+ }
144
+ export async function compareWrite(db, table, row, definition) {
145
+ if (definition == null) throw new Error(`Table "${table}" has no sync declaration`);
146
+ if (definition.key.length === 0 || definition.compare.length === 0) {
147
+ throw new Error('Sync declarations require key and compare columns');
148
+ }
149
+ const columns = Object.keys(row);
150
+ const stored = (column)=>sql.ref(`${table}.${column}`);
151
+ const incoming = (column)=>sql.ref(`excluded.${column}`);
152
+ const tuple = (names, ref)=>sql`(${sql.join(names.map(ref))})`;
153
+ const { compare } = definition;
154
+ let predicate;
155
+ if (definition.nullsOldest) {
156
+ const alternatives = compare.map((column, index)=>{
157
+ const equal = compare.slice(0, index).map((previous)=>sql`${incoming(previous)} IS NOT DISTINCT FROM ${stored(previous)}`);
158
+ const greater = sql`((${stored(column)} IS NULL AND ${incoming(column)} IS NOT NULL) OR ${incoming(column)} > ${stored(column)})`;
159
+ return sql`(${sql.join([
160
+ ...equal,
161
+ greater
162
+ ], sql` AND `)})`;
163
+ });
164
+ if (definition.inclusive) {
165
+ alternatives.push(sql`(${sql.join(compare.map((column)=>sql`${incoming(column)} IS NOT DISTINCT FROM ${stored(column)}`), sql` AND `)})`);
166
+ }
167
+ predicate = sql`(${sql.join(alternatives, sql` OR `)})`;
168
+ } else {
169
+ predicate = sql`${tuple(compare, incoming)} ${definition.inclusive ? sql`>=` : sql`>`} ${tuple(compare, stored)}`;
170
+ }
171
+ if (definition.skipUnchanged) {
172
+ predicate = sql`(${predicate}) AND ${tuple(columns, stored)} IS DISTINCT FROM ${tuple(columns, incoming)}`;
173
+ }
174
+ const query = db;
175
+ const updates = Object.fromEntries(columns.map((column)=>[
176
+ column,
177
+ incoming(column)
178
+ ]));
179
+ const statement = query.insertInto(table).values(row).onConflict((conflict)=>conflict.columns(definition.key).doUpdateSet(updates).where(predicate)).returning(definition.key);
180
+ const compiled = statement.compile();
181
+ if (compiled.parameters.length > 500) {
182
+ throw new Error('compareWrite cannot bind more than 500 parameters');
183
+ }
184
+ const result = await db.executeQuery(compiled);
185
+ return {
186
+ changed: result.rows.length > 0
187
+ };
188
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hozon/db",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Database registry, migrations and transactions for Hozon",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,13 +17,13 @@
17
17
  "lib/*"
18
18
  ],
19
19
  "dependencies": {
20
- "@hozon/adapter": "^0.1.0",
20
+ "@hozon/adapter": "^0.2.1",
21
21
  "@sozai/async": "^0.4.1",
22
22
  "@sozai/log": "^0.4.0",
23
23
  "kysely": "^0.29.7"
24
24
  },
25
25
  "devDependencies": {
26
- "@hozon/node-sqlite": "^0.1.0",
26
+ "@hozon/node-sqlite": "^0.2.0",
27
27
  "@types/node": "^26.6.5",
28
28
  "del-cli": "^8.0.1",
29
29
  "vitest": "5.0.5"