@jarenjs/linq 0.56.0 → 0.66.1

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.
@@ -0,0 +1,195 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `createDbLedger(client, options)`: the contract ledger
4
+ * (`@jarenjs/contract` docs/CONTRACT-FORMAT.md §8) over a declared
5
+ * collection of the client's store — every claim state, the persisted
6
+ * generation fence, expiry, `sweep` — through the client's collection
7
+ * and transaction surface alone. Nothing here imports the contract
8
+ * package or a driver: the ledger SHAPE is structural (the
9
+ * `claim`/`commit`/`fail`/`lookup` the http binding calls), the record
10
+ * is exactly `idempotencyLedgerModel`'s, and the id is the same
11
+ * versioned JSON tuple the memory ledger spells. A root client claims
12
+ * inside `transaction(…, { mode: 'immediate' })` — one writer, so two
13
+ * processes never both hold `new`; a transaction client claims and
14
+ * settles inside the transaction it was handed, which is the ledger a
15
+ * lifecycle settlement lease carries: a domain write and the settlement
16
+ * then commit together or not at all.
17
+ */
18
+
19
+ import { resolveRuntime } from '@jarenjs/core/runtime';
20
+
21
+ import { LinqRuntimeError } from '../errors.js';
22
+
23
+ /** One day, the default retention of a key — the memory ledger's. */
24
+ const DEFAULT_TTL_MS = 86_400_000;
25
+
26
+ /** The collection `idempotencyLedgerModel` declares. */
27
+ const DEFAULT_COLLECTION = 'ledger';
28
+
29
+ /**
30
+ * The id of one `(op, scope, key)` tuple — the version `1`, a colon, the
31
+ * JSON array of the three: the spelling `@jarenjs/contract/ledger`'s
32
+ * `ledgerId` writes, so a store's records and a memory ledger's agree.
33
+ * A record written under the legacy `"<op>|<scope>|<key>"` spelling is
34
+ * matched by no claim again; it expires by its own `expiresAt` (`sweep`)
35
+ * or a host rewrites it once (DB-CLIENT.md §2.6).
36
+ * @param {string} op
37
+ * @param {string} scope
38
+ * @param {string} key
39
+ * @returns {string}
40
+ */
41
+ function ledgerId(op, scope, key) {
42
+ return `1:${JSON.stringify([op, scope, key])}`;
43
+ }
44
+
45
+ /**
46
+ * @typedef {Object} DbLedgerOptions
47
+ * @property {string} [collection] - the declared collection, `'ledger'` by default
48
+ * @property {number} [ttlMs] - the retention of a key, 86,400,000 ms by default
49
+ * @property {Partial<import('@jarenjs/core/runtime').Runtime>} [runtime] -
50
+ * the host's runtime record: its `now` is the clock, its `uuid` mints
51
+ * every generation
52
+ * @property {() => number} [now] - the clock; wins over the runtime's
53
+ */
54
+
55
+ /**
56
+ * @param {unknown} ref
57
+ * @param {string} reason
58
+ * @returns {LinqRuntimeError}
59
+ */
60
+ function stale(ref, reason) {
61
+ const r = /** @type {any} */ (ref);
62
+ const named = r !== null && typeof r === 'object' && typeof r.id === 'string' ? r.id : 'a ref this ledger did not issue';
63
+ return new LinqRuntimeError('JL2007', `${named} settles no started record: ${reason}`);
64
+ }
65
+
66
+ /**
67
+ * The durable ledger over a client's declared collection.
68
+ * @param {any} client - a `@jarenjs/linq/db` client: the root one, or
69
+ * the one a transaction callback received
70
+ * @param {DbLedgerOptions} [options]
71
+ * @returns {{ claim: (claim: { op: string, scope: string, key: string, hash: string, now?: number }) => Promise<any>,
72
+ * commit: (ref: unknown, response: unknown, now?: number) => Promise<void>,
73
+ * fail: (ref: unknown, retryable: boolean, response?: unknown, now?: number) => Promise<void>,
74
+ * lookup: (key: { op: string, scope: string, key: string, now?: number }) => Promise<any>,
75
+ * sweep: (now?: number) => Promise<number> }}
76
+ */
77
+ export function createDbLedger(client, options = {}) {
78
+ if (client === null || typeof client !== 'object' || client.collections === null
79
+ || typeof client.collections !== 'object' || typeof client.transaction !== 'function') {
80
+ throw new TypeError('createDbLedger: client must be a @jarenjs/linq/db client — the root client, or the one a transaction callback received');
81
+ }
82
+ const name = options.collection === undefined ? DEFAULT_COLLECTION : options.collection;
83
+ if (typeof name !== 'string' || name === '') throw new TypeError('createDbLedger: collection must be a non-empty collection name');
84
+ if (!Object.hasOwn(client.collections, name)) {
85
+ throw new TypeError(`createDbLedger: the client declares no collection '${name}' — open the store with idempotencyLedgerModel, or a model that declares it`);
86
+ }
87
+ const ttlMs = options.ttlMs === undefined ? DEFAULT_TTL_MS : options.ttlMs;
88
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new TypeError('createDbLedger: ttlMs must be a positive number');
89
+ let runtime;
90
+ try {
91
+ runtime = resolveRuntime(options.runtime);
92
+ }
93
+ catch (error) {
94
+ throw new TypeError(`createDbLedger: runtime: ${error instanceof Error ? error.message : String(error)}`);
95
+ }
96
+ const clock = options.now === undefined ? runtime.now : options.now;
97
+ if (typeof clock !== 'function') throw new TypeError('createDbLedger: now must be a function');
98
+ // the memory ledger's clock discipline: one clock judges a record from
99
+ // claim to expiry — its own when given, else the binding's instants
100
+ const ownClock = options.now !== undefined || options.runtime !== undefined;
101
+ /** @type {number | null} */
102
+ let latest = null;
103
+ /** @param {number | undefined} given */
104
+ const instant = (given) => {
105
+ if (typeof given === 'number') {
106
+ if (!ownClock) latest = latest === null ? given : Math.max(latest, given);
107
+ return given;
108
+ }
109
+ return ownClock || latest === null ? clock() : latest;
110
+ };
111
+ // a root client owns a connection and takes the write lock up front; a
112
+ // transaction client is inside one already and nests a savepoint, so
113
+ // the settlement commits with the host's own writes
114
+ const root = typeof client.close === 'function';
115
+ /** @param {(tx: any) => Promise<any>} fn */
116
+ const inside = (fn) => (root ? client.transaction(fn, { mode: 'immediate' }) : client.transaction(fn));
117
+ /** @param {any} tx */
118
+ const rows = (tx) => tx.collections[name];
119
+
120
+ /**
121
+ * Settle the started record a ref names, or refuse (`JL2007`).
122
+ * @param {unknown} ref
123
+ * @param {{ status: 'committed' | 'failed', response: unknown, retryable: boolean | null }} changes
124
+ * @param {number | undefined} now
125
+ */
126
+ function settle(ref, changes, now) {
127
+ const r = /** @type {any} */ (ref);
128
+ if (r === null || typeof r !== 'object' || typeof r.id !== 'string' || typeof r.generation !== 'string') {
129
+ return Promise.reject(stale(ref, 'a ref is { id, generation } as this ledger issued it'));
130
+ }
131
+ const at = instant(now);
132
+ return inside(async (tx) => {
133
+ const c = rows(tx);
134
+ const record = await c.get(r.id);
135
+ if (record === undefined || record.generation !== r.generation || record.status !== 'started') {
136
+ throw stale(ref, 'the key expired, was reclaimed under a newer generation, or was settled already');
137
+ }
138
+ await c.put({ ...record, ...changes, updatedAt: at }, r.id);
139
+ });
140
+ }
141
+
142
+ return Object.freeze({
143
+ claim({ op, scope, key, hash, now }) {
144
+ const at = instant(now);
145
+ const id = ledgerId(op, scope, key);
146
+ return inside(async (tx) => {
147
+ const c = rows(tx);
148
+ const existing = await c.get(id);
149
+ if (existing !== undefined) {
150
+ if (existing.expiresAt <= at) await c.delete(id);
151
+ else if (existing.hash !== hash) return { state: 'mismatch' };
152
+ else if (existing.status === 'started') return { state: 'in-progress' };
153
+ else if (existing.status === 'committed') return { state: 'replay', response: existing.response };
154
+ else if (existing.retryable !== true && existing.response !== null) return { state: 'replay', response: existing.response };
155
+ else await c.delete(id); // a retryable failure: the key runs again
156
+ }
157
+ const generation = runtime.uuid();
158
+ await c.insert({
159
+ id, generation, op, scope, key, hash, status: 'started', response: null, retryable: null,
160
+ createdAt: at, updatedAt: at, expiresAt: at + ttlMs,
161
+ });
162
+ return { state: 'new', ref: Object.freeze({ id, generation }) };
163
+ });
164
+ },
165
+ commit(ref, response, now = undefined) {
166
+ return settle(ref, { status: 'committed', response, retryable: null }, now);
167
+ },
168
+ fail(ref, retryable, response = undefined, now = undefined) {
169
+ return settle(ref, { status: 'failed', response: response === undefined ? null : response, retryable: retryable === true }, now);
170
+ },
171
+ lookup({ op, scope, key, now = undefined }) {
172
+ const at = instant(now);
173
+ const id = ledgerId(op, scope, key);
174
+ return inside(async (tx) => {
175
+ const c = rows(tx);
176
+ const record = await c.get(id);
177
+ if (record === undefined) return null;
178
+ if (record.expiresAt <= at) {
179
+ await c.delete(id);
180
+ return null;
181
+ }
182
+ return record; // the store's fresh document: nothing of the ledger's own
183
+ });
184
+ },
185
+ sweep(now = undefined) {
186
+ const at = instant(now);
187
+ return inside(async (tx) => {
188
+ const c = rows(tx);
189
+ const expired = await c.where((/** @type {any} */ r) => r.expiresAt.le(at)).select((/** @type {any} */ r) => r.id).toArray();
190
+ for (const id of expired) await c.delete(id);
191
+ return expired.length;
192
+ });
193
+ },
194
+ });
195
+ }
package/src/db/open.js CHANGED
@@ -55,21 +55,69 @@ export async function open(model, options) {
55
55
  storeOptions.compileSchema = (schema) => jaren.compile(schema);
56
56
  }
57
57
  const store = await openStore(model, storeOptions);
58
- const entities = {};
59
- for (const name of store.roots ?? []) {
60
- setObjectMember(entities, name, createEntityHandle(store, name));
61
- }
62
- const collections = {};
63
- for (const name of Object.keys(model.collections ?? {})) {
64
- setObjectMember(collections, name, createCollectionHandle(store, name));
65
- }
58
+
59
+ /**
60
+ * The typed handles over ONE store view — the root store, or the one
61
+ * a transaction callback received. Building them from the same two
62
+ * constructors is what keeps a transaction's `entities.X` the same
63
+ * surface, with the same inference, as the client's own.
64
+ * @param {any} over
65
+ */
66
+ const handlesOf = (over) => {
67
+ const entities = {};
68
+ for (const name of over.roots ?? []) {
69
+ setObjectMember(entities, name, createEntityHandle(over, name));
70
+ }
71
+ const collections = {};
72
+ for (const name of Object.keys(model.collections ?? {})) {
73
+ setObjectMember(collections, name, createCollectionHandle(over, name));
74
+ }
75
+ return { entities: Object.freeze(entities), collections: Object.freeze(collections) };
76
+ };
77
+
78
+ /**
79
+ * The client a transaction callback receives: the same shape as the
80
+ * root client, over the store that is INSIDE the transaction. Its
81
+ * handles run as the transaction's owner rather than waiting for a
82
+ * commit they are part of, and its `transaction` nests.
83
+ *
84
+ * Built per transaction, because the handles bind to the store view —
85
+ * and the whole point of a transaction's own unit of work is that two
86
+ * handlers do not share one.
87
+ * @param {any} tx - the store the callback received
88
+ */
89
+ const transactionClient = (tx) => {
90
+ /** @type {Record<string, any>} */
91
+ const inner = {
92
+ store: tx,
93
+ capabilities: tx.capabilities,
94
+ ...handlesOf(tx),
95
+ transaction: (fn) => tx.transaction((nested) => fn(transactionClient(nested))),
96
+ // the named-savepoint group (MODEL-FORMAT §5.2), forwarded as it
97
+ // is: partial rollback belongs to the transaction that owns the
98
+ // connection, so the root client deliberately has no twin
99
+ savepoints: tx.savepoints,
100
+ };
101
+ if (tx.saveChanges !== undefined) {
102
+ inner.saveChanges = () => tx.saveChanges();
103
+ inner.live = (source, liveOptions) => registerLive(tx.live, source, liveOptions);
104
+ }
105
+ return Object.freeze(inner);
106
+ };
107
+
66
108
  /** @type {Record<string, any>} */
67
109
  const client = {
68
110
  store,
69
111
  capabilities: store.capabilities,
70
- entities: Object.freeze(entities),
71
- collections: Object.freeze(collections),
72
- transaction: (fn) => store.transaction(fn),
112
+ ...handlesOf(store),
113
+ // A transaction gets its own unit of work by default: two handlers
114
+ // on one client then hold two records for the same entity key and
115
+ // neither can see the other's pending state. `unitOfWork: 'shared'`
116
+ // opts back into the store's, for a caller who staged changes
117
+ // outside the transaction and means to save them inside it.
118
+ transaction: (fn, transactionOptions) => store.transaction(
119
+ (tx) => fn(transactionClient(tx)),
120
+ { unitOfWork: 'own', ...transactionOptions }),
73
121
  close: (closeOptions) => store.close(closeOptions),
74
122
  };
75
123
  // the unit of work and entity live queries exist exactly when the
package/src/errors.js CHANGED
@@ -27,7 +27,7 @@ export const LINQ_CODES = Object.freeze({
27
27
  JL0102: 'a pen was asked for a construct the format cannot carry',
28
28
  JL0103: 'a $defs name collision, a dangling ref, or an unnamed recursion',
29
29
  JL0104: 'a pen-owned keyword through meta(), or an external a captured rule did not declare',
30
- JL0105: 'a relation hop cannot lower: a many-to-many member, a composite or undeclared key, or a malformed relation entry',
30
+ JL0105: 'a relation hop cannot lower: a composite or undeclared key, an incomplete many-to-many entry, or a malformed relation record',
31
31
  JL0106: 'a migration step names a table the target model does not declare, or a draft it cannot match',
32
32
  JL0107: 'a client operation named a member that is not the relation kind it needs',
33
33
  JL2001: 'first/single found no element',
@@ -36,6 +36,8 @@ export const LINQ_CODES = Object.freeze({
36
36
  JL2004: 'an asynchronous provider cannot back the synchronous surface',
37
37
  JL2005: 'a push queue was fed after it ended',
38
38
  JL2006: 'a provider answered an element terminal with something other than one array',
39
+ JL2007: 'a ledger settlement named a ref that settles no started record',
40
+ JL2008: 'a federated fetch reached its row or byte budget',
39
41
  });
40
42
 
41
43
  /**
@@ -136,10 +138,17 @@ export class LinqBuildError extends CodedError {
136
138
  * the provider directly instead.
137
139
  * - `JL2005` — `feed()` was called on a push queue after `end()`
138
140
  * closed it (a condition of the running stream, not of the build)
141
+ * - `JL2007` — `createDbLedger`'s `commit`/`fail` named a ref that
142
+ * settles no started record: the key expired, was reclaimed under a
143
+ * newer generation, or was settled already (DB-CLIENT.md §2.6)
139
144
  * - `JL2006` — a provider answered an element terminal (`toArray`,
140
145
  * `first`, …) with something other than exactly one array; the
141
146
  * emitted document is an array constructor, so a conforming
142
147
  * `execute()` never answers `undefined` there
148
+ * - `JL2008` — a `federate()` fetch reached one side's row or byte
149
+ * budget. A budget is a REFUSAL, not a spill: the fetch stops at the
150
+ * row that would have broken it, every cursor it opened is closed,
151
+ * and the reason names the side and the bound (QUERY-PEN.md §13)
143
152
  */
144
153
  export class LinqRuntimeError extends CodedError {
145
154
  /**
package/src/expression.js CHANGED
@@ -512,10 +512,18 @@ function checkRelation(relation, member) {
512
512
  + '({ to, kind, via, fkEntity, fkTargets, targetKey } — MODEL-FORMAT §10.1)');
513
513
  }
514
514
  if (relation.kind === 'manyToMany') {
515
- throw new LinqBuildError('JL0105',
516
- `'${member}' is a many-to-many relation: the join table '${relation.joinTable}' is not a `
517
- + 'queryable root in this version, so the hop has no phrase to lower to — read the '
518
- + `memberships with load({ include: { ${member}: true } })`);
515
+ // the join table is a read-only query root (MODEL-FORMAT §10.7), so
516
+ // the hop lowers through it provided the relation record names
517
+ // the row's two columns and the key each references
518
+ if (typeof relation.joinTable !== 'string' || typeof relation.ownColumn !== 'string'
519
+ || typeof relation.ownKey !== 'string' || typeof relation.targetColumn !== 'string'
520
+ || typeof relation.targetKey !== 'string') {
521
+ throw new LinqBuildError('JL0105',
522
+ `'${member}' is a many-to-many relation whose entry does not name its join row's `
523
+ + 'columns ({ joinTable, ownColumn, ownKey, targetColumn, targetKey } — '
524
+ + 'MODEL-FORMAT §10.1), so the hop has no phrase to lower to');
525
+ }
526
+ return;
519
527
  }
520
528
  if (relation.kind !== 'oneToOne' && relation.kind !== 'oneToMany') {
521
529
  throw new LinqBuildError('JL0105',
@@ -599,6 +607,24 @@ function startHop(target, member) {
599
607
  many = true;
600
608
  fan = true;
601
609
  }
610
+ if (relation.kind === 'manyToMany') {
611
+ // TWO links, numbered in chain order: the join row that names the
612
+ // membership, then the target row it names. The join table is a
613
+ // query root of its own, carrying exactly the two key columns (§10.7)
614
+ const joinBinding = `r${sink.next++}`;
615
+ const binding = `r${sink.next++}`;
616
+ chain.push({ binding: joinBinding, source: entityRootOf(relation.joinTable),
617
+ where: { $eq: [`$${joinBinding}${memberSegment(relation.ownColumn)}`,
618
+ `${subject}${memberSegment(relation.ownKey)}`] } });
619
+ chain.push({ binding, source: entityRootOf(relation.to),
620
+ where: { $eq: [`$${binding}${memberSegment(relation.targetKey)}`,
621
+ `$${joinBinding}${memberSegment(relation.targetColumn)}`] } });
622
+ sink.hops.push({ member, kind: relation.kind, binding });
623
+ const manyTarget = resolve(relation.to);
624
+ return makeHop({ chain, ret: '$' + binding, many: true, fan },
625
+ target.epoch,
626
+ manyTarget === undefined ? undefined : { table: manyTarget, resolve, sink });
627
+ }
602
628
  const binding = `r${sink.next++}`;
603
629
  const where = relation.kind === 'oneToMany'
604
630
  ? { $eq: [`$${binding}${memberSegment(relation.via)}`, `${subject}${memberSegment(relation.targetKey)}`] }