@hozon/db 0.3.0 → 0.4.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 } from './errors.js';
6
6
  import { LoaderRegistry } from './loader-registry.js';
7
7
  import { checkStore } 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,6 +50,12 @@ 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) {
@@ -67,7 +78,7 @@ export class HozonDB {
67
78
  if (store == null) {
68
79
  throw new Error(`Store "${name}" is not registered`);
69
80
  }
70
- this.#storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, this.#loaders));
81
+ this.#storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, this.#loaders, this));
71
82
  }
72
83
  return this.#storeAPIs.get(name);
73
84
  }
@@ -86,6 +97,7 @@ export class HozonDB {
86
97
  });
87
98
  const storeAPIs = new Map();
88
99
  const provider = {
100
+ isTransactionBound: false,
89
101
  getStore: async (name)=>{
90
102
  this.#assertOpen();
91
103
  await this.#ensurePreflight();
@@ -94,7 +106,7 @@ export class HozonDB {
94
106
  if (!storeAPIs.has(name)) {
95
107
  const store = this.#stores.get(name);
96
108
  if (store == null) throw new Error(`Store "${name}" is not registered`);
97
- storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, registry));
109
+ storeAPIs.set(name, this.#createStoreAPI(store, this.#db, this.#storeDB, registry, provider));
98
110
  }
99
111
  return storeAPIs.get(name);
100
112
  },
@@ -144,70 +156,114 @@ export class HozonDB {
144
156
  result = await this.#db.transaction().execute(async (trx)=>{
145
157
  const txKysely = trx;
146
158
  const txStoreDB = txKysely.withPlugin(this.#defaultPlugin);
147
- const txStoreAPIs = new Map();
148
- const createScopedProvider = (scopedCommitHooks, scopedRollbackHooks)=>{
159
+ const createScopedProvider = (scopedCommitHooks, scopedRollbackHooks, transactionRoot, syncParent, syncSavepoint = false, scopeState = {
160
+ savepointOpen: false,
161
+ settled: false,
162
+ savepointBound: syncSavepoint
163
+ })=>{
149
164
  // Savepoints on one connection form a stack: a scope holds at most
150
165
  // one open savepoint, and nesting goes through that savepoint's
151
166
  // own provider.
152
- let savepointOpen = false;
167
+ const assertActive = ()=>{
168
+ if (scopeState.settled) throw new SavepointReleasedError();
169
+ };
170
+ const txStoreAPIs = new Map();
153
171
  const provider = {
172
+ isTransactionBound: true,
173
+ get transactionRoot () {
174
+ return transactionRoot ?? provider;
175
+ },
154
176
  getStore: async (name)=>{
177
+ assertActive();
178
+ this.#assertOpen();
155
179
  if (!txStoreAPIs.has(name)) {
156
180
  const store = this.#stores.get(name);
157
181
  if (store == null) {
158
182
  throw new Error(`Store "${name}" is not registered`);
159
183
  }
160
- txStoreAPIs.set(name, this.#createStoreAPI(store, txKysely, txStoreDB, registry));
184
+ txStoreAPIs.set(name, this.#createStoreAPI(store, txKysely, txStoreDB, registry, provider, scopeState.savepointBound ? assertActive : undefined));
161
185
  }
162
186
  return txStoreAPIs.get(name);
163
187
  },
164
- hasStore: (name)=>this.#stores.has(name),
188
+ hasStore: (name)=>{
189
+ assertActive();
190
+ return this.#stores.has(name);
191
+ },
165
192
  onCommit: (hook)=>{
193
+ assertActive();
166
194
  scopedCommitHooks.push(hook);
167
195
  },
168
196
  onRollback: (hook)=>{
197
+ assertActive();
169
198
  scopedRollbackHooks.push(hook);
170
199
  },
171
200
  withScope: async (nestedFn)=>{
201
+ assertActive();
172
202
  return await nestedFn(provider);
173
203
  },
174
204
  withTransaction: async (nestedFn)=>{
175
205
  // Already inside a transaction — Kysely does not nest.
176
206
  // Run `nestedFn` inline with the same provider so its hooks and
177
207
  // writes remain within the enclosing transaction or savepoint.
208
+ assertActive();
178
209
  return await nestedFn(provider);
179
210
  },
180
211
  withSavepoint: async (nestedFn)=>{
181
- if (savepointOpen) throw new SavepointOverlapError();
182
- savepointOpen = true;
212
+ assertActive();
213
+ if (scopeState.savepointOpen) throw new SavepointOverlapError();
214
+ scopeState.savepointOpen = true;
183
215
  try {
184
216
  const name = `${this.#tablePrefix}_sp_${++savepointID}`;
185
217
  const savepointCommitHooks = [];
186
218
  const savepointRollbackHooks = [];
187
- const scopedProvider = createScopedProvider(savepointCommitHooks, savepointRollbackHooks);
219
+ const savepointState = {
220
+ savepointOpen: false,
221
+ settled: false,
222
+ savepointBound: true
223
+ };
224
+ const scopedProvider = createScopedProvider(savepointCommitHooks, savepointRollbackHooks, provider.transactionRoot, provider, true, savepointState);
188
225
  registry.dispatchAll();
189
226
  await sql.raw(`SAVEPOINT ${name}`).execute(trx);
190
227
  try {
191
228
  const value = await nestedFn(scopedProvider);
192
229
  registry.dispatchAll();
193
230
  await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
194
- scopedCommitHooks.push(...savepointCommitHooks);
195
- scopedRollbackHooks.push(...savepointRollbackHooks);
231
+ savepointState.settled = true;
232
+ registry.clearAll();
233
+ for (const hook of savepointCommitHooks)provider.onCommit(hook);
234
+ for (const hook of savepointRollbackHooks)provider.onRollback(hook);
196
235
  return value;
197
236
  } catch (error) {
198
237
  registry.dispatchAll();
199
238
  await sql.raw(`ROLLBACK TO SAVEPOINT ${name}`).execute(trx);
200
239
  registry.dispatchAll();
201
240
  await sql.raw(`RELEASE SAVEPOINT ${name}`).execute(trx);
241
+ savepointState.settled = true;
202
242
  registry.clearAll();
203
243
  this.#runHooks(savepointRollbackHooks, 'rollback');
204
244
  throw error;
245
+ } finally{
246
+ savepointState.settled = true;
247
+ settleSyncProvider(scopedProvider);
205
248
  }
206
249
  } finally{
207
- savepointOpen = false;
250
+ scopeState.savepointOpen = false;
208
251
  }
209
252
  }
210
253
  };
254
+ bindSyncProvider(provider, {
255
+ assertActive,
256
+ derive: ()=>createScopedProvider(scopedCommitHooks, scopedRollbackHooks, provider.transactionRoot, provider, false, scopeState),
257
+ db: txKysely,
258
+ kind: this.#adapter.kind,
259
+ tablePrefix: this.#tablePrefix,
260
+ tableName: (table)=>{
261
+ const unprefixed = [
262
+ ...this.#stores.values()
263
+ ].some((store)=>store.unprefixedTables?.(table));
264
+ return unprefixed ? table : `${this.#tablePrefix}_${table}`;
265
+ }
266
+ }, syncParent, syncSavepoint);
211
267
  return provider;
212
268
  };
213
269
  try {
@@ -260,6 +316,17 @@ export class HozonDB {
260
316
  return this.#closing;
261
317
  }
262
318
  async #close() {
319
+ // Publish #closing before a hook can re-enter close().
320
+ await Promise.resolve();
321
+ for (const hook of this.#closeHooks){
322
+ try {
323
+ await hook();
324
+ } catch (error) {
325
+ this.#logger.error('close hook failed', {
326
+ error
327
+ });
328
+ }
329
+ }
263
330
  try {
264
331
  await this.#db.destroy();
265
332
  } finally{
@@ -294,13 +361,31 @@ export class HozonDB {
294
361
  }
295
362
  return migrations;
296
363
  }
297
- // Hands stores only createLoader, so they cannot reach close/dispatchAll/clearAll.
298
364
  // Stores on the default plugin must share one Kysely instance: keep-set locks key on it.
299
- #createStoreAPI(store, kysely, defaultDB, registry) {
365
+ #createStoreAPI(store, kysely, defaultDB, registry, provider, assertActive) {
300
366
  const storeDB = store.unprefixedTables == null ? defaultDB : kysely.withPlugin(this.#getStorePlugin(store));
301
- return store.createAPI(storeDB, this.#adapter, {
302
- createLoader: (params)=>registry.createLoader(params)
367
+ const base = {
368
+ compareWrite: async (table, row)=>{
369
+ assertActive?.();
370
+ return await compareWrite(storeDB, table, row, store.sync?.[table]);
371
+ },
372
+ createLoader: (params)=>{
373
+ assertActive?.();
374
+ return registry.createLoader(params);
375
+ },
376
+ provider,
377
+ root: this,
378
+ onClose: (fn)=>{
379
+ this.#assertOpen();
380
+ this.#closeHooks.push(fn);
381
+ }
382
+ };
383
+ const ext = this.#context?.({
384
+ provider,
385
+ root: this
303
386
  });
387
+ const context = Object.defineProperties(base, Object.getOwnPropertyDescriptors(ext ?? {}));
388
+ return store.createAPI(storeDB, this.#adapter, context);
304
389
  }
305
390
  #getStorePlugin(store) {
306
391
  if (store.unprefixedTables == null) return this.#defaultPlugin;
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
  }
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');
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, } 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 } 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
  }
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.4.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.0",
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"