@jarenjs/db 0.66.1 → 0.67.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/src/store.js CHANGED
@@ -29,16 +29,21 @@ import { chain, toPromise, isThenable, attempt } from './driver.js';
29
29
  import { planCollection, planEntity, planJoinTable, verifyShape } from './ddl.js';
30
30
  import { translatePatch } from './patch-sql.js';
31
31
  import { createQueryEngine, createQueryState, createEntityQueryEngine, createLoadEngine } from './query.js';
32
- import { admitCursor, admitSyncCursor } from './cursor.js';
32
+ import { admitCursor, admitSyncCursor, createCursor, drainPage, utf8Length } from './cursor.js';
33
33
  import { refuseUnsupportedPragmaKeys, resolvePragmaRequests, configurePragmas } from './pragmas.js';
34
34
  import { createMaintenance } from './maintenance.js';
35
35
  import { createBackup } from './backup.js';
36
36
  import { normalizeProfile, assertProfileRoots } from './profile.js';
37
- import { normalizeEntities, explainMapping } from './model.js';
37
+ import { normalizeEntities, explainMapping, joinTableRoots } from './model.js';
38
38
  import { entityCore } from './entity.js';
39
39
  import { createTracker, membershipKeys } from './tracker.js';
40
40
  import { createCaptureEngine, DEFAULT_RETENTION } from './capture.js';
41
+ import { createReplicationEngine } from './replication.js';
42
+ import { REPLICATION_DEFAULTS } from './replication-format.js';
43
+ import { createLogicalRows } from './logical-rows.js';
44
+ import { shapeHash } from './migrate.js';
41
45
  import { createLiveRegistry, classifyLiveQuery, LIVE_DEFAULTS } from './live.js';
46
+ import { classifyEntityLive } from './live-join.js';
42
47
  import { normalizeEventTime } from './live-time.js';
43
48
  import { createJobEngine } from './jobs.js';
44
49
  import { introspectModel } from './introspect.js';
@@ -961,6 +966,9 @@ export function openStore(model, options) {
961
966
  collections = normalizeModel(model, options.expressions);
962
967
  entities = normalizeEntities(model);
963
968
  mapping = entities.size > 0 ? explainMapping(model) : null;
969
+ if (options.replication !== undefined && [...collections.keys(), ...entities.keys()]
970
+ .some((name) => name.toLowerCase().startsWith('_jaren_replica')))
971
+ throw new DbCompileError('JD0060', 'replication reserves table names beginning with _jaren_replica');
964
972
  if (collections.size === 0 && entities.size === 0) {
965
973
  throw modelError('JD0005',
966
974
  'the model must declare at least one collection or entity', '');
@@ -1439,10 +1447,13 @@ export function openStore(model, options) {
1439
1447
  };
1440
1448
 
1441
1449
  // ————— change capture (LIVE-FORMAT §§1–6) —————
1442
- const captureRequested = options.capture === true
1450
+ const captureOption = options.capture ?? (options.replication === undefined ? undefined : true);
1451
+ const captureRequested = captureOption === true
1443
1452
  ? {}
1444
- : (options.capture === undefined || options.capture === false
1445
- ? null : options.capture);
1453
+ : (captureOption === undefined || captureOption === false
1454
+ ? null : captureOption);
1455
+ if (options.replication !== undefined && (captureRequested === null || readOnly))
1456
+ throw new TypeError('replication requires a writable store with capture enabled');
1446
1457
  let captureMode = 'none';
1447
1458
  if (captureRequested !== null) {
1448
1459
  const wanted = captureRequested.mode ?? 'auto';
@@ -1468,6 +1479,9 @@ export function openStore(model, options) {
1468
1479
  ? (hasSessions ? 'session' : 'journal')
1469
1480
  : wanted;
1470
1481
  }
1482
+ if (options.replication !== undefined && captureMode === 'journal'
1483
+ && Object.values(mapping?.entities ?? {}).some((entity) => entity.foreignKeys.some((fk) => fk.onDelete !== 'restrict')))
1484
+ throw new DbCompileError('JD0051', 'journal replication cannot capture cascading or set-null child relations; use session capture');
1471
1485
  const captureShapes = new Map();
1472
1486
  if (captureMode !== 'none') {
1473
1487
  for (const [collectionName, plan] of plans) {
@@ -1525,6 +1539,7 @@ export function openStore(model, options) {
1525
1539
  // a column upgrade; a read-only store creates nothing and takes
1526
1540
  // no lock
1527
1541
  const firstOpen = readOnly ? (fn) => fn() : (fn) => immediately(connection, fn);
1542
+ let replicationEngine = null;
1528
1543
  const capture = captureMode === 'none' ? null : createCaptureEngine({
1529
1544
  connection,
1530
1545
  bracket: firstOpen,
@@ -1534,6 +1549,7 @@ export function openStore(model, options) {
1534
1549
  || (captureRequested.log !== undefined && captureRequested.log !== false),
1535
1550
  retention: captureRequested.log?.retention ?? DEFAULT_RETENTION,
1536
1551
  now: runtime.now,
1552
+ beforeCommit: (patch, context) => replicationEngine?.commit(patch, context),
1537
1553
  });
1538
1554
  // the capture scope around a write runs statements of its own
1539
1555
  // (a session's changeset read, the journal's old-row read, the
@@ -1562,6 +1578,7 @@ export function openStore(model, options) {
1562
1578
  const liveRegistry = capture === null ? null : createLiveRegistry({
1563
1579
  maxQueries: options.live?.maxQueries ?? LIVE_DEFAULTS.maxQueries,
1564
1580
  maxMaintained: options.live?.maxMaintained ?? LIVE_DEFAULTS.maxMaintained,
1581
+ maxBytes: options.live?.maxBytes ?? LIVE_DEFAULTS.maxBytes,
1565
1582
  });
1566
1583
  if (capture !== null) {
1567
1584
  capture.observe((record) => /** @type {any} */ (liveRegistry).deliver(record));
@@ -1613,6 +1630,8 @@ export function openStore(model, options) {
1613
1630
  classification,
1614
1631
  execute: (doc, executeOptions) => core.execute(doc, executeOptions),
1615
1632
  readRow: (token) => core.get(token),
1633
+ rowPosition: (token) => createLogicalRows({ connection, shapes: captureShapes, capture,
1634
+ collectionCore: coreFor, entityCore: entityCoreFor }).position(core.model.name, token),
1616
1635
  keyOf: (doc) => String(extractKey(doc, core.model.keySegments,
1617
1636
  core.model.key, core.model.name, core.model.docPath)),
1618
1637
  }));
@@ -1655,13 +1674,24 @@ export function openStore(model, options) {
1655
1674
  const sql = `SELECT ${columns.map(dialect.quoteIdentifier).join(', ')} `
1656
1675
  + `FROM ${dialect.quoteIdentifier(joinName)} `
1657
1676
  + `WHERE ${dialect.quoteIdentifier(own.column)} = ${dialect.parameterRef(1, 'v')}`;
1658
- return chain(connection.prepare(sql), (statement) =>
1659
- chain(statement.all([keyParts[0]]), (rows) => {
1677
+ const boundedRows = () => {
1678
+ const { maxOperations = REPLICATION_DEFAULTS.maxOperations, maxBytes = REPLICATION_DEFAULTS.maxBytes } = options.replication;
1679
+ const cursor = createCursor({ streaming: 'row', barrier: null,
1680
+ open: () => chain(connection.prepare(`${sql} LIMIT ?`), (statement) => statement.iterate([keyParts[0], maxOperations + 1])),
1681
+ items: (row) => [row] });
1682
+ return chain(drainPage(cursor, { limit: maxOperations, maxBytes,
1683
+ sizeOf: (row) => utf8Length(JSON.stringify(row)), continuationOf: () => null }), (page) => {
1684
+ if (page.hasMore) throw new DbRuntimeError('JD2106', 'membership cascade exceeds replication capacity');
1685
+ return page.items;
1686
+ });
1687
+ };
1688
+ return chain(options.replication === undefined
1689
+ ? chain(connection.prepare(sql), (statement) => statement.all([keyParts[0]])) : boundedRows(), (rows) => {
1660
1690
  for (const row of rows) {
1661
1691
  capture.record(joinName, columns.map((column) => row[column]), undefined, null);
1662
1692
  }
1663
1693
  return nextJoin(i + 1);
1664
- }));
1694
+ });
1665
1695
  };
1666
1696
  return nextJoin(0);
1667
1697
  };
@@ -2170,23 +2200,27 @@ export function openStore(model, options) {
2170
2200
  'live eventTime maintains a collection view — an entity document re-runs, '
2171
2201
  + 'so a watermark would describe nothing (LIVE-FORMAT §13)');
2172
2202
  }
2173
- const roots = collectEntityRoots(document, entities);
2203
+ const roots = collectEntityRoots(document, new Map([...entities, ...joinTableRoots(entities, mapping).entities]));
2174
2204
  if (roots.size === 0) {
2175
2205
  throw new TypeError(
2176
2206
  'store.live takes an entity-root document — for a collection, '
2177
2207
  + 'use store.collection(name).live');
2178
2208
  }
2209
+ const logicalRows = createLogicalRows({ connection, shapes: captureShapes, capture,
2210
+ collectionCore: coreFor, entityCore: entityCoreFor });
2179
2211
  return closeOnRollback(liveRegistry.register({
2180
2212
  name: [...roots].join('+'),
2181
2213
  tables: roots,
2182
2214
  document,
2183
2215
  externals: liveOptions?.externals ?? {},
2184
2216
  demanded: liveOptions?.mode,
2185
- classification: {
2217
+ classification: liveOptions?.mode === 'rerun' ? {
2186
2218
  strategy: 'rerun',
2187
- reason: 'entity queries re-run in this version',
2188
- },
2219
+ reason: 're-run mode was explicitly requested',
2220
+ } : classifyEntityLive(document, entities, mapping, operators),
2189
2221
  execute: (doc, executeOptions) => entityEngine.execute(doc, executeOptions),
2222
+ readDependency: logicalRows.read,
2223
+ dependencyPosition: logicalRows.position,
2190
2224
  readRow: null,
2191
2225
  keyOf: null,
2192
2226
  }));
@@ -2754,6 +2788,7 @@ export function openStore(model, options) {
2754
2788
  // member is ABSENT rather than a second way to close the
2755
2789
  // raw connection under its own savepoint
2756
2790
  close: override(undefined),
2791
+ replication: override(undefined),
2757
2792
  // nor does it run maintenance: a checkpoint inside an open
2758
2793
  // transaction is a no-op the engine answers quietly, and
2759
2794
  // the other three are store-level operations — ABSENT here
@@ -3010,8 +3045,26 @@ export function openStore(model, options) {
3010
3045
  });
3011
3046
  }
3012
3047
  return chain(capture === null ? null : capture.ready,
3013
- () => chain(jobsEngine === null ? null : jobsEngine.ready,
3014
- () => Object.freeze(store)));
3048
+ () => chain(jobsEngine === null ? null : jobsEngine.ready, () => {
3049
+ if (options.replication !== undefined) {
3050
+ replicationEngine = createReplicationEngine({ connection, capture,
3051
+ config: options.replication, model: shapeHash(model), now: runtime.now, bracket: firstOpen,
3052
+ rows: createLogicalRows({ connection, shapes: captureShapes, capture,
3053
+ collectionCore: coreFor, entityCore: entityCoreFor, captureJoinDelete }),
3054
+ });
3055
+ store.replication = Object.freeze({
3056
+ frontier: lift(() => gated(() => replicationEngine.frontier())),
3057
+ page: lift((request) => topLevelTransaction(() => replicationEngine.page(request), request?.signal, undefined, 'immediate')),
3058
+ conflicts: lift((request) => gated(() => replicationEngine.conflicts(request), 'replication conflict read', request?.signal)),
3059
+ snapshot: lift((request) => topLevelTransaction(() => replicationEngine.snapshot(request), request?.signal, undefined, 'immediate')),
3060
+ reset: lift((snapshot, request) => replicationEngine.reset(snapshot, request,
3061
+ (fn) => topLevelTransaction(fn, request?.signal, createUnitOfWork(), 'immediate'))),
3062
+ apply: lift((envelope, request) => replicationEngine.apply(envelope, request,
3063
+ (fn) => topLevelTransaction(fn, request?.signal, createUnitOfWork(), 'immediate'))),
3064
+ });
3065
+ }
3066
+ return chain(replicationEngine === null ? null : replicationEngine.ready, () => Object.freeze(store));
3067
+ }));
3015
3068
  })))));
3016
3069
 
3017
3070
  /**
package/types/index.d.ts CHANGED
@@ -709,6 +709,7 @@ export interface SyncStore {
709
709
  }
710
710
 
711
711
  export interface Store {
712
+ readonly replication?: Replication;
712
713
  readonly capabilities: StoreCapabilities;
713
714
  readonly dialect: Dialect;
714
715
  stats(): StoreStats;
@@ -867,7 +868,7 @@ export interface TransactionSyncStore extends SyncStore {
867
868
  */
868
869
  export interface TransactionStore extends Omit<Store,
869
870
  'close' | 'transaction' | 'sync' | 'checkpoint' | 'integrityCheck' | 'foreignKeyCheck' | 'optimize' | 'backupTo'
870
- | 'jobs'> {
871
+ | 'jobs' | 'replication'> {
871
872
  /** The transactional outbox (JOBS-FORMAT §3): no administration here —
872
873
  * an admin operation is a root call. */
873
874
  readonly jobs?: JobsApi;
@@ -978,7 +979,7 @@ export interface LiveEventTime {
978
979
 
979
980
  export interface LiveMode {
980
981
  readonly strategy: 'rows' | 'window' | 'accumulator' | 'group'
981
- | 'bucket' | 'rolling' | 'rerun';
982
+ | 'bucket' | 'rolling' | 'join' | 'graph' | 'nested-group' | 'rerun';
982
983
  readonly mode: 'incremental' | 'rerun';
983
984
  /** Present exactly when the strategy is 'rerun': the named reason. */
984
985
  readonly reason?: string;
@@ -1004,6 +1005,9 @@ export interface LiveEvent {
1004
1005
  }
1005
1006
 
1006
1007
  export interface LiveStats {
1008
+ dependencyReads?: number;
1009
+ refreshedRoots?: number;
1010
+ refreshedGroups?: number;
1007
1011
  records: number;
1008
1012
  matched: number;
1009
1013
  emissions: number;
@@ -1037,6 +1041,8 @@ export interface LiveQuery {
1037
1041
  }
1038
1042
 
1039
1043
  export interface LiveBounds {
1044
+ /** Serialized input/output cache credit for join, graph and nested-group strategies. */
1045
+ maxBytes?: number;
1040
1046
  /** Registrations beyond it are JD0052 (default 64). */
1041
1047
  maxQueries?: number;
1042
1048
  /** Per-query ceiling on maintained entries — rows, window entries
@@ -1045,6 +1051,7 @@ export interface LiveBounds {
1045
1051
  }
1046
1052
 
1047
1053
  export interface OpenStoreOptions {
1054
+ replication?: ReplicationOptions;
1048
1055
  driver: Driver;
1049
1056
  path?: string;
1050
1057
  /**
@@ -1605,7 +1612,7 @@ export declare function createLiveRegistry(
1605
1612
  bounds: { maxQueries: number; maxMaintained: number }): unknown;
1606
1613
  export declare function diffRows(oldRows: readonly unknown[], newRows: readonly unknown[]):
1607
1614
  Array<{ op: string; path: string; value?: unknown }>;
1608
- export declare const LIVE_DEFAULTS: { maxQueries: number; maxMaintained: number };
1615
+ export declare const LIVE_DEFAULTS: { maxQueries: number; maxMaintained: number; maxBytes: number };
1609
1616
  export declare function createSortedWindow(
1610
1617
  terms: unknown[], limit: number | null): unknown;
1611
1618
  export declare function compareCodepoint(a: string, b: string): number;
@@ -1898,3 +1905,55 @@ export declare const JOB_DEFAULTS: Readonly<{
1898
1905
  export declare function describeValue(value: unknown): string;
1899
1906
  /** A job result as the queue stores it: JSON text, or the reason it could not be. */
1900
1907
  export declare function serializeResult(value: unknown): unknown;
1908
+
1909
+ /** Transport-neutral, net logical operations in a single transaction. */
1910
+ export interface ReplicationOperation {
1911
+ table: string;
1912
+ key: string;
1913
+ before: Record<string, unknown> | null;
1914
+ after: Record<string, unknown> | null;
1915
+ }
1916
+ export type ReplicationFrontier = Record<string, number>;
1917
+ export interface ReplicationEnvelope {
1918
+ $replication: '0.1'; replica: string; seq: number; model: string;
1919
+ frontier: ReplicationFrontier; operations: ReplicationOperation[];
1920
+ }
1921
+ export interface ReplicationConflict {
1922
+ envelope: string; table: string; key: string;
1923
+ base: Record<string, unknown> | null;
1924
+ local: { value: Record<string, unknown> | null; frontier: ReplicationFrontier };
1925
+ remote: { value: Record<string, unknown> | null; replica: string; seq: number; frontier: ReplicationFrontier };
1926
+ resolver: string | null;
1927
+ resolution: { action: 'local' | 'remote' | 'merged'; value: Record<string, unknown> | null } | null;
1928
+ }
1929
+ export interface ReplicationOptions {
1930
+ replica: string; retention?: number; maxOperations?: number; maxBytes?: number;
1931
+ resolver?: { id: string; resolve(conflict: Readonly<ReplicationConflict>):
1932
+ { action: 'local' | 'remote' } | { action: 'merged'; value: Record<string, unknown> | null } };
1933
+ }
1934
+ export interface ReplicationRequest { signal?: AbortSignal; deadline?: number }
1935
+ export interface Replication {
1936
+ snapshot(request?: ReplicationRequest): Promise<ReplicationSnapshot>;
1937
+ reset(snapshot: ReplicationSnapshot, request?: ReplicationRequest): Promise<{ status: 'reset'; frontier: ReplicationFrontier }>;
1938
+ frontier(): Promise<ReplicationFrontier>;
1939
+ apply(envelope: ReplicationEnvelope, request?: ReplicationRequest): Promise<{
1940
+ status: 'applied' | 'duplicate' | 'conflict'; frontier: ReplicationFrontier; conflicts: ReplicationConflict[];
1941
+ }>;
1942
+ page(request?: ReplicationRequest & { after?: number; limit?: number; maxBytes?: number }): Promise<{
1943
+ items: ReplicationEnvelope[]; earliestAvailable: number | null; highWatermark: number;
1944
+ next?: number; bytes?: number; hasMore: boolean; resetRequired: boolean;
1945
+ }>;
1946
+ conflicts(request?: ReplicationRequest & { limit?: number; maxBytes?: number }): Promise<ReplicationConflict[]>;
1947
+ }
1948
+ export declare const REPLICATION_VERSION: '0.1';
1949
+ export declare const REPLICATION_DEFAULTS: Readonly<{ retention: number; maxOperations: number; maxBytes: number }>;
1950
+ export declare function normalizeFrontier(value: unknown): ReplicationFrontier;
1951
+ export declare function replicationIdentity(replica: string, seq: number): string;
1952
+ export declare function normalizeReplication(document: unknown): ReplicationEnvelope;
1953
+ export declare function encodeReplication(document: unknown): string;
1954
+ export interface ReplicationSnapshot {
1955
+ $replicationSnapshot: '0.1'; model: string; frontier: ReplicationFrontier;
1956
+ rows: { table: string; key: string; value: Record<string, unknown> | null; frontier: ReplicationFrontier }[];
1957
+ receipts: ReplicationEnvelope[];
1958
+ }
1959
+ export declare function normalizeReplicationSnapshot(document: unknown): ReplicationSnapshot;
package/types/typed.d.ts CHANGED
@@ -17,7 +17,7 @@ import type {
17
17
  StoreStats, Collection, ExecuteOptions, SequenceResult, ValueOrPromise,
18
18
  Dialect, ChangeRecord, LiveOptions, LiveQuery, JobsApi, SyncStore,
19
19
  EntityScope, RelationEntry, RelationTable, EntityCursorOptions, QueryCursor,
20
- LoadContinuation, PageOptions, Page, ChangesReader,
20
+ LoadContinuation, PageOptions, Page, ChangesReader, Replication, TransactionStore,
21
21
  } from '@jarenjs/db';
22
22
 
23
23
  /** The self-referential constraint an interface can satisfy: generated
@@ -164,7 +164,7 @@ export interface TypedStore<E extends MetaMap<E>> {
164
164
  /** The relation tables of every entity, keyed by entity name. */
165
165
  readonly relations?: Readonly<Record<keyof E & string, RelationTable>>;
166
166
  saveChanges?(): Promise<SaveReport>;
167
- transaction<R>(fn: (store: Store) => R | Promise<R>): Promise<Awaited<R>>;
167
+ transaction<R>(fn: (store: TransactionStore) => R | Promise<R>): Promise<Awaited<R>>;
168
168
  observe(fn: (record: ChangeRecord) => void): () => void;
169
169
  /** Unbounded, and unsafe for a reconnecting consumer: `changes.page()`
170
170
  * is the supported path (LIVE-FORMAT §5). */
@@ -174,6 +174,7 @@ export interface TypedStore<E extends MetaMap<E>> {
174
174
  live?(document: unknown, options?: LiveOptions): Promise<LiveQuery>;
175
175
  close(options?: { graceMs?: number }): Promise<void>;
176
176
  readonly jobs?: JobsApi;
177
+ readonly replication?: Replication;
177
178
  readonly sync?: SyncStore;
178
179
  }
179
180