@minnowdb/core 0.7.10 → 0.8.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.
@@ -0,0 +1,226 @@
1
+ import { childExpressions, hasAggregate, mapChildExpressions, executeRowQueryInternal, inferResultColumnDomains, externalizeQueryResult } from "./query.js";
2
+ import { encodeQueryIdentity } from "./query-identity.js";
3
+ import { exactNumericBinary, exactNumericValue, externalSqlDomainValue, isExactNumeric } from "./sql-domains.js";
4
+ import { encodeSqlEqualityValue } from "./sql-semantics.js";
5
+ class LiveAggregate {
6
+ inputPlan;
7
+ #outputPlan;
8
+ #aggregates;
9
+ #groupAliases;
10
+ #rows;
11
+ #groups;
12
+ #domains;
13
+ keyAlias;
14
+ #schema;
15
+ constructor(inputPlan, outputPlan, aggregates, groupAliases, keyAlias, rows = /* @__PURE__ */ new Map(), groups = /* @__PURE__ */ new Map(), domains = [], schema = []) {
16
+ this.inputPlan = inputPlan;
17
+ this.#outputPlan = outputPlan;
18
+ this.#aggregates = aggregates;
19
+ this.#groupAliases = groupAliases;
20
+ this.keyAlias = keyAlias;
21
+ this.#rows = rows;
22
+ this.#groups = groups;
23
+ this.#domains = domains;
24
+ this.#schema = schema;
25
+ }
26
+ static plan(plan, qualifiedKey) {
27
+ const aggregates = [];
28
+ const groupAliases = plan.groupBy.map((_, index) => `__minnow_live_group_${String(index)}`);
29
+ const groupKeys = new Map(plan.groupBy.map((expression, index) => [
30
+ encodeQueryIdentity(expression),
31
+ groupAliases[index] ?? ""
32
+ ]));
33
+ const rowLocal = (expression) => !hasAggregate(expression) && !["subquery", "exists", "window", "parameter", "wildcard"].includes(expression.kind) && childExpressions(expression).every(rowLocal);
34
+ if (!plan.groupBy.every(rowLocal))
35
+ return void 0;
36
+ const rewrite = (expression) => {
37
+ const grouped = groupKeys.get(encodeQueryIdentity(expression));
38
+ if (grouped !== void 0)
39
+ return { kind: "column", reference: grouped };
40
+ if (expression.kind === "call" && hasAggregate(expression)) {
41
+ if (!["COUNT", "SUM", "AVG"].includes(expression.name) || expression.arguments.length !== 1)
42
+ throw new TypeError("Not an additive live aggregate");
43
+ const argument = expression.arguments[0];
44
+ if (argument === void 0 || !(rowLocal(argument) || expression.name === "COUNT" && argument.kind === "wildcard"))
45
+ throw new TypeError("Not a row-local aggregate argument");
46
+ const alias = `__minnow_live_value_${String(aggregates.length)}`;
47
+ aggregates.push({
48
+ name: expression.name,
49
+ alias,
50
+ argument: argument.kind === "wildcard" ? { kind: "literal", value: 1 } : argument
51
+ });
52
+ return { kind: "column", reference: alias };
53
+ }
54
+ if (expression.kind === "column" || expression.kind === "window" || expression.kind === "subquery" || expression.kind === "exists")
55
+ throw new TypeError("Not a grouped live expression");
56
+ return mapChildExpressions(expression, rewrite);
57
+ };
58
+ try {
59
+ const select = plan.select.map((item) => ({ ...item, expression: rewrite(item.expression) }));
60
+ const having = plan.having.map((predicate) => ({
61
+ ...predicate,
62
+ left: rewrite(predicate.left),
63
+ right: rewrite(predicate.right)
64
+ }));
65
+ const outputAliases = new Set(select.map((item) => item.alias));
66
+ const orderBy = plan.orderBy.map((term) => ({
67
+ ...term,
68
+ expression: term.expression.kind === "column" && outputAliases.has(term.expression.reference) ? term.expression : rewrite(term.expression)
69
+ }));
70
+ if (aggregates.length === 0)
71
+ return void 0;
72
+ const keyAlias = "__minnow_live_aggregate_key";
73
+ const inputPlan = {
74
+ ...plan,
75
+ select: [
76
+ { alias: keyAlias, expression: { kind: "column", reference: qualifiedKey } },
77
+ ...plan.groupBy.map((expression, index) => ({
78
+ alias: groupAliases[index] ?? "",
79
+ expression
80
+ })),
81
+ ...aggregates.map(({ alias, argument }) => ({ alias, expression: argument }))
82
+ ],
83
+ groupBy: [],
84
+ having: [],
85
+ orderBy: []
86
+ };
87
+ delete inputPlan.limit;
88
+ delete inputPlan.offset;
89
+ const outputPlan = {
90
+ ...plan,
91
+ base: { table: "__minnow_live_groups", alias: "__minnow_live_groups" },
92
+ joins: [],
93
+ select,
94
+ predicates: having,
95
+ groupBy: [],
96
+ having: [],
97
+ orderBy
98
+ };
99
+ return new LiveAggregate(inputPlan, outputPlan, aggregates, groupAliases, keyAlias);
100
+ } catch {
101
+ return void 0;
102
+ }
103
+ }
104
+ patch(result, changed, token) {
105
+ const rows = new Map(this.#rows);
106
+ const groups = new Map(this.#groups);
107
+ const touched = /* @__PURE__ */ new Set();
108
+ const groupFor = (key, keys = []) => {
109
+ let group = groups.get(key);
110
+ if (group === void 0) {
111
+ group = {
112
+ keys,
113
+ members: 0,
114
+ counts: this.#aggregates.map(() => 0),
115
+ sums: this.#aggregates.map(() => exactNumericValue(0) ?? ""),
116
+ absolute: this.#aggregates.map(() => 0)
117
+ };
118
+ groups.set(key, group);
119
+ touched.add(key);
120
+ } else if (!touched.has(key)) {
121
+ group = {
122
+ ...group,
123
+ counts: [...group.counts],
124
+ sums: [...group.sums],
125
+ absolute: [...group.absolute]
126
+ };
127
+ groups.set(key, group);
128
+ touched.add(key);
129
+ }
130
+ return group;
131
+ };
132
+ const apply = (group, values, sign) => {
133
+ group.members += sign;
134
+ for (const [index, aggregate] of this.#aggregates.entries()) {
135
+ const value = values[index] ?? null;
136
+ if (value === null)
137
+ continue;
138
+ group.counts[index] = (group.counts[index] ?? 0) + sign;
139
+ if (aggregate.name !== "COUNT") {
140
+ if (typeof value !== "number" && !isExactNumeric(value))
141
+ throw new TypeError("Live SUM requires numeric values");
142
+ if (typeof value === "number") {
143
+ if (!Number.isSafeInteger(value))
144
+ throw new TypeError("Floating-point aggregates require full execution");
145
+ group.absolute[index] = (group.absolute[index] ?? 0) + sign * Math.abs(value);
146
+ if (!Number.isSafeInteger(group.absolute[index]))
147
+ throw new TypeError("Aggregate sum may round; requires full execution");
148
+ }
149
+ const sum = exactNumericBinary(sign === 1 ? "+" : "-", group.sums[index] ?? exactNumericValue(0), exactNumericValue(value));
150
+ if (sum === null || sum === void 0)
151
+ throw new TypeError("Invalid numeric aggregate contribution");
152
+ group.sums[index] = sum;
153
+ }
154
+ }
155
+ };
156
+ for (const key of changed) {
157
+ const old = rows.get(key);
158
+ if (old === void 0)
159
+ continue;
160
+ apply(groupFor(old.group), old.values, -1);
161
+ rows.delete(key);
162
+ }
163
+ for (const row of result.rows) {
164
+ const key = token(row[this.keyAlias] ?? null);
165
+ const keys = this.#groupAliases.map((alias) => row[alias] ?? null);
166
+ const group = JSON.stringify(keys.map(encodeSqlEqualityValue));
167
+ const values = this.#aggregates.map(({ alias }) => row[alias] ?? null);
168
+ apply(groupFor(group, keys), values, 1);
169
+ rows.set(key, { group, values });
170
+ }
171
+ if (this.#groupAliases.length === 0)
172
+ groupFor("[]");
173
+ else
174
+ for (const key of touched)
175
+ if (groups.get(key)?.members === 0)
176
+ groups.delete(key);
177
+ const domains = this.#aggregates.map(({ alias }, index) => result.columnDomains[result.columns.indexOf(alias)] ?? this.#domains[index] ?? null);
178
+ return new LiveAggregate(this.inputPlan, this.#outputPlan, this.#aggregates, this.#groupAliases, this.keyAlias, rows, groups, domains, result.columns.map((name, index) => {
179
+ const domain = result.columnDomains[index];
180
+ const value = result.rows.find((row) => row[name] !== null)?.[name];
181
+ const aggregate = this.#aggregates.find((item) => item.alias === name);
182
+ return {
183
+ name,
184
+ type: aggregate?.name === "COUNT" ? "number" : domain !== null && domain !== void 0 ? "string" : typeof value === "number" ? "number" : typeof value === "boolean" ? "boolean" : value instanceof Date ? "datetime" : "string",
185
+ ...domain === null || domain === void 0 || aggregate?.name === "COUNT" ? {} : { sqlDomain: domain }
186
+ };
187
+ }));
188
+ }
189
+ result() {
190
+ const rows = [];
191
+ for (const group of this.#groups.values()) {
192
+ const row = {};
193
+ for (const [index, alias] of this.#groupAliases.entries())
194
+ row[alias] = group.keys[index] ?? null;
195
+ for (const [index, aggregate] of this.#aggregates.entries()) {
196
+ const count = group.counts[index] ?? 0;
197
+ const domain = this.#domains[index];
198
+ let value = aggregate.name === "COUNT" ? count : count === 0 ? null : group.sums[index] ?? null;
199
+ if (value !== null && aggregate.name !== "COUNT") {
200
+ if (domain?.kind !== "numeric") {
201
+ value = Number(externalSqlDomainValue(value));
202
+ if (aggregate.name === "AVG")
203
+ value /= count;
204
+ } else if (aggregate.name === "AVG")
205
+ value = exactNumericBinary("/", value, count, domain.scale) ?? null;
206
+ }
207
+ row[aggregate.alias] = value;
208
+ }
209
+ rows.push(row);
210
+ }
211
+ const result = executeRowQueryInternal(this.#outputPlan, /* @__PURE__ */ new Map([["__minnow_live_groups", rows]]));
212
+ result.columnDomains = inferResultColumnDomains(this.#outputPlan, /* @__PURE__ */ new Map([["__minnow_live_groups", this.#schema]]));
213
+ return externalizeQueryResult(result);
214
+ }
215
+ get retainedBytes() {
216
+ let bytes = 256 + encodeQueryIdentity(this.inputPlan).length * 2 + encodeQueryIdentity(this.#outputPlan).length * 2;
217
+ for (const [key, row] of this.#rows)
218
+ bytes += 64 + key.length * 2 + row.group.length * 2 + row.values.reduce((sum, value) => sum + (typeof value === "string" ? value.length * 2 + 16 : 16), 0);
219
+ for (const [key, group] of this.#groups)
220
+ bytes += 96 + key.length * 2 + group.sums.reduce((sum, value) => sum + value.length * 2 + 24, 0);
221
+ return bytes;
222
+ }
223
+ }
224
+ export {
225
+ LiveAggregate
226
+ };
@@ -0,0 +1,21 @@
1
+ import type { QueryResult, QueryRow } from "../plan/model.js";
2
+ import type { LiveQueryDelivery } from "./live.js";
3
+ export type LiveQueryPatch = {
4
+ readonly type: "reset";
5
+ readonly result: QueryResult;
6
+ } | {
7
+ readonly type: "patch";
8
+ /** Each next position's previous position, or -1 for a changed/new row. */
9
+ readonly retained: Int32Array;
10
+ readonly changedRows: ReadonlyArray<{
11
+ readonly index: number;
12
+ readonly row: QueryRow;
13
+ }>;
14
+ };
15
+ export interface LiveQueryPatchOptions {
16
+ onPatch(patch: LiveQueryPatch, delivery: LiveQueryDelivery): void;
17
+ onError?(error: unknown): void;
18
+ onComplete?(): void;
19
+ }
20
+ /** Copy only changed payloads when provenance is available; resets establish a fresh baseline. */
21
+ export declare function createLiveQueryPatch(result: QueryResult, delivery: LiveQueryDelivery): LiveQueryPatch;
@@ -0,0 +1,31 @@
1
+ import { copyDate } from "../date-value.js";
2
+ function copyRow(row) {
3
+ const copy = { ...row };
4
+ for (const key of Object.keys(copy)) {
5
+ const value = copy[key];
6
+ if (value instanceof Date)
7
+ copy[key] = copyDate(value);
8
+ }
9
+ return copy;
10
+ }
11
+ function createLiveQueryPatch(result, delivery) {
12
+ if (delivery.retained === void 0 || delivery.initial)
13
+ return {
14
+ type: "reset",
15
+ result: {
16
+ columns: [...result.columns],
17
+ columnDomains: structuredClone(result.columnDomains),
18
+ rows: result.rows.map(copyRow)
19
+ }
20
+ };
21
+ const changedRows = [];
22
+ for (let index = 0; index < result.rows.length; index += 1) {
23
+ const row = result.rows[index];
24
+ if (row !== void 0 && (delivery.retained[index] ?? -1) < 0)
25
+ changedRows.push({ index, row: copyRow(row) });
26
+ }
27
+ return { type: "patch", retained: new Int32Array(delivery.retained), changedRows };
28
+ }
29
+ export {
30
+ createLiveQueryPatch
31
+ };
@@ -1,3 +1,5 @@
1
+ import { type LiveQueryPatchOptions } from "./live-patch.js";
2
+ export type { LiveQueryPatch, LiveQueryPatchOptions } from "./live-patch.js";
1
3
  import { type CatalogProbe, type Manifest, type StoragePage } from "../storage/types.js";
2
4
  import { type CompiledQuery, type QueryResult, type QueryValue } from "./query.js";
3
5
  /**
@@ -27,6 +29,8 @@ export interface LiveQuerySetOptions {
27
29
  readonly maxGroups?: number;
28
30
  /** Maximum result/observer subscriptions retained by this set. Defaults to 1,024. */
29
31
  readonly maxSubscriptions?: number;
32
+ /** Maximum modeled resident result and maintenance bytes per set. Defaults to 64 MiB. */
33
+ readonly maxRetainedBytes?: number;
30
34
  /**
31
35
  * Hand `onChange` the set's retained result instead of a private copy. The result is shared
32
36
  * with every equal subscription and with the next change comparison, so a subscriber must
@@ -64,6 +68,8 @@ export interface LiveQueryDelivery {
64
68
  readonly retained?: Int32Array;
65
69
  }
66
70
  export interface LiveQuerySubscribeOptions {
71
+ /** Borrow the read-only retained result for this subscription instead of copying it. */
72
+ readonly sharedResults?: boolean;
67
73
  onChange(result: QueryResult, delivery: LiveQueryDelivery): void;
68
74
  onError?(error: unknown): void;
69
75
  /** Called once when the subscription ends because the subscription or its set closed. */
@@ -114,6 +120,8 @@ export interface LiveQueryStats {
114
120
  * bounded by the engine at 64 rows and is not included.
115
121
  */
116
122
  retainedRows: number;
123
+ /** Modeled resident result and incremental maintenance bytes, counted once per group. */
124
+ retainedBytes: number;
117
125
  /** Work avoided because equal statements shared one query group or in-flight execution. */
118
126
  sharedExecutions: number;
119
127
  lastSweepMs: number;
@@ -146,6 +154,8 @@ export interface LiveQueryExecuteContext {
146
154
  export interface LiveMaintainedExecution {
147
155
  readonly result: QueryResult;
148
156
  readonly state: unknown;
157
+ /** Modeled total bytes retained by the result and opaque maintenance state. */
158
+ readonly retainedBytes?: number;
149
159
  }
150
160
  export interface LiveMaintainedChange extends LiveMaintainedExecution {
151
161
  readonly changed: boolean;
@@ -178,6 +188,8 @@ export declare class LiveQuerySet {
178
188
  get stats(): LiveQueryStats;
179
189
  /** Registers a query, delivers its current result, and shares work with equal statements. */
180
190
  subscribe(query: LiveQueryInput, options: LiveQuerySubscribeOptions): Promise<LiveQuerySubscription>;
191
+ /** Delivers resets or changed row payloads without constructing a private full row array per patch. */
192
+ subscribePatches(query: LiveQueryInput, options: LiveQueryPatchOptions): Promise<LiveQuerySubscription>;
181
193
  /** Observes invalidation; the statement executes inside the set only when asked to compare. */
182
194
  observe(query: LiveQueryInput, options: LiveQueryObserveOptions): Promise<LiveQuerySubscription>;
183
195
  /** Called by the owning database after each local write commit; also hints other tabs. */
@@ -1,3 +1,6 @@
1
+ import { createLiveQueryPatch } from "./live-patch.js";
2
+ import { queryResultRetainedBytes } from "./query-cache.js";
3
+ import { encodeQueryIdentity } from "./query-identity.js";
1
4
  import { LiveQueryLimitError } from "./errors.js";
2
5
  const DEFAULT_LIVE_QUERY_MAX_GROUPS = 256;
3
6
  const DEFAULT_LIVE_QUERY_MAX_SUBSCRIPTIONS = 1024;
@@ -63,41 +66,6 @@ function cloneResult(result) {
63
66
  rows: result.rows.map((row) => cloneRow(row, columns))
64
67
  };
65
68
  }
66
- function encodeQueryIdentity(value, ancestors = /* @__PURE__ */ new Set()) {
67
- if (value === null)
68
- return "z";
69
- if (typeof value === "undefined")
70
- return "u";
71
- if (typeof value === "boolean")
72
- return value ? "b1" : "b0";
73
- if (typeof value === "number") {
74
- if (Number.isNaN(value))
75
- return "nNaN;";
76
- if (Object.is(value, -0))
77
- return "n-0;";
78
- return `n${String(value)};`;
79
- }
80
- if (typeof value === "string")
81
- return `s${String(value.length)}:${value}`;
82
- if (value instanceof Date)
83
- return `d${String(dateMilliseconds(value))};`;
84
- if (typeof value !== "object") {
85
- throw new TypeError(`Unsupported live-query identity value: ${typeof value}`);
86
- }
87
- if (ancestors.has(value))
88
- throw new TypeError("Live-query identity contains a cycle");
89
- ancestors.add(value);
90
- let encoded;
91
- if (Array.isArray(value)) {
92
- encoded = `a${String(value.length)}[${value.map((item) => encodeQueryIdentity(item, ancestors)).join("")}]`;
93
- } else {
94
- const record = value;
95
- const keys = Object.keys(record).sort();
96
- encoded = `o${String(keys.length)}{${keys.map((key) => `${encodeQueryIdentity(key, ancestors)}${encodeQueryIdentity(record[key], ancestors)}`).join("")}}`;
97
- }
98
- ancestors.delete(value);
99
- return encoded;
100
- }
101
69
  function queryKey(query) {
102
70
  return encodeQueryIdentity(typeof query === "string" ? ["sql", query] : query.kind === "sql-query" ? ["sql-query", query.sql, query.params] : ["typed-query", query.plan]);
103
71
  }
@@ -141,6 +109,12 @@ function groupMemoizes(group) {
141
109
  }
142
110
  return false;
143
111
  }
112
+ function callLiveCallback(callback) {
113
+ try {
114
+ callback?.();
115
+ } catch {
116
+ }
117
+ }
144
118
  class LiveQuerySet {
145
119
  #host;
146
120
  #channel;
@@ -153,6 +127,7 @@ class LiveQuerySet {
153
127
  #lagging = /* @__PURE__ */ new Set();
154
128
  #maxGroups;
155
129
  #maxSubscriptions;
130
+ #maxRetainedBytes;
156
131
  #sharedResults;
157
132
  #incremental;
158
133
  #stats = {
@@ -167,6 +142,7 @@ class LiveQuerySet {
167
142
  maintained: 0,
168
143
  groupsVisited: 0,
169
144
  retainedRows: 0,
145
+ retainedBytes: 0,
170
146
  sharedExecutions: 0,
171
147
  lastSweepMs: 0
172
148
  };
@@ -187,6 +163,9 @@ class LiveQuerySet {
187
163
  }
188
164
  this.#maxGroups = boundedLiveLimit(options.maxGroups ?? DEFAULT_LIVE_QUERY_MAX_GROUPS, MAX_LIVE_QUERY_GROUPS, "group");
189
165
  this.#maxSubscriptions = boundedLiveLimit(options.maxSubscriptions ?? DEFAULT_LIVE_QUERY_MAX_SUBSCRIPTIONS, MAX_LIVE_QUERY_SUBSCRIPTIONS, "subscription");
166
+ this.#maxRetainedBytes = options.maxRetainedBytes ?? 64 * 1024 * 1024;
167
+ if (!Number.isSafeInteger(this.#maxRetainedBytes) || this.#maxRetainedBytes < 0)
168
+ throw new RangeError("Live query retained byte limit must be a non-negative safe integer");
190
169
  this.#sharedResults = options.sharedResults === true;
191
170
  this.#incremental = options.incremental !== false;
192
171
  this.#host = host;
@@ -212,7 +191,10 @@ class LiveQuerySet {
212
191
  let retainedRows = 0;
213
192
  for (const group of this.#groups.values())
214
193
  retainedRows += group.result?.rows.length ?? 0;
215
- return { ...this.#stats, retainedRows };
194
+ let retainedBytes = 0;
195
+ for (const group of this.#groups.values())
196
+ retainedBytes += group.retainedBytes;
197
+ return { ...this.#stats, retainedRows, retainedBytes };
216
198
  }
217
199
  #freshProbe() {
218
200
  let pending = this.#pendingProbe;
@@ -274,6 +256,14 @@ class LiveQuerySet {
274
256
  }
275
257
  return this.#subscriptionHandle(group, subscriber);
276
258
  }
259
+ subscribePatches(query, options) {
260
+ return this.subscribe(query, {
261
+ sharedResults: true,
262
+ onChange: (result, delivery) => options.onPatch(createLiveQueryPatch(result, delivery), delivery),
263
+ ...options.onError === void 0 ? {} : { onError: options.onError.bind(options) },
264
+ ...options.onComplete === void 0 ? {} : { onComplete: options.onComplete.bind(options) }
265
+ });
266
+ }
277
267
  async observe(query, options) {
278
268
  this.#throwIfClosed();
279
269
  this.#reserveSubscription();
@@ -338,7 +328,7 @@ class LiveQuerySet {
338
328
  this.#subscriptionCount -= 1;
339
329
  group.subscribers.delete(subscriber);
340
330
  if (complete)
341
- subscriber.options.onComplete?.();
331
+ callLiveCallback(() => subscriber.options.onComplete?.());
342
332
  this.#removeEmptyGroup(group);
343
333
  }
344
334
  #removeEmptyGroup(group) {
@@ -382,6 +372,7 @@ class LiveQuerySet {
382
372
  subscribers: /* @__PURE__ */ new Set(),
383
373
  seenProbe: after,
384
374
  result: void 0,
375
+ retainedBytes: 0,
385
376
  execution: void 0,
386
377
  deliveries: 0,
387
378
  maintenance: void 0,
@@ -445,6 +436,7 @@ class LiveQuerySet {
445
436
  await this.#acquireExecutionSlot();
446
437
  let executed;
447
438
  let maintenance;
439
+ let retainedBytes;
448
440
  try {
449
441
  if (this.#incremental && this.#host.executeMaintainable !== void 0 && !group.unmaintainable) {
450
442
  const maintained = await this.#host.executeMaintainable(group.query, context);
@@ -453,6 +445,7 @@ class LiveQuerySet {
453
445
  else {
454
446
  executed = maintained.result;
455
447
  maintenance = maintained.state;
448
+ retainedBytes = maintained.retainedBytes;
456
449
  }
457
450
  }
458
451
  executed ??= await this.#host.execute(group.query, context);
@@ -461,8 +454,7 @@ class LiveQuerySet {
461
454
  }
462
455
  const previous = group.result;
463
456
  const changed = previous === void 0 || !sameResult(previous, executed);
464
- group.result = executed;
465
- group.maintenance = maintenance;
457
+ this.#retain(group, executed, maintenance, retainedBytes);
466
458
  return { result: executed, changed };
467
459
  })();
468
460
  group.execution = execution;
@@ -473,6 +465,18 @@ class LiveQuerySet {
473
465
  group.execution = void 0;
474
466
  }
475
467
  }
468
+ #retain(group, result, state, hint) {
469
+ const bytes = Math.max(queryResultRetainedBytes(result) + result.rows.length * 48, hint ?? (state === group.maintenance ? group.retainedBytes : 0));
470
+ let total = bytes;
471
+ for (const other of this.#groups.values())
472
+ if (other !== group)
473
+ total += other.retainedBytes;
474
+ if (!Number.isSafeInteger(total) || total > this.#maxRetainedBytes)
475
+ throw new LiveQueryLimitError("byte", this.#maxRetainedBytes);
476
+ group.result = result;
477
+ group.maintenance = state;
478
+ group.retainedBytes = bytes;
479
+ }
476
480
  async #acquireExecutionSlot() {
477
481
  if (this.#executing < LIVE_QUERY_EXECUTION_CONCURRENCY) {
478
482
  this.#executing += 1;
@@ -509,8 +513,7 @@ class LiveQuerySet {
509
513
  verdict.declined = true;
510
514
  return { result: retained, changed: false };
511
515
  }
512
- group.result = maintained.result;
513
- group.maintenance = maintained.state;
516
+ this.#retain(group, maintained.result, maintained.state, maintained.retainedBytes);
514
517
  return {
515
518
  result: maintained.result,
516
519
  changed: maintained.changed,
@@ -531,7 +534,7 @@ class LiveQuerySet {
531
534
  return;
532
535
  subscriber.delivered = true;
533
536
  const consecutive = subscriber.seenDelivery === group.deliveries - 1;
534
- subscriber.options.onChange(this.#sharedResults ? result : cloneResult(result), retained !== void 0 && consecutive && !delivery.initial ? { ...delivery, retained } : delivery);
537
+ subscriber.options.onChange(this.#sharedResults || subscriber.options.sharedResults === true ? result : cloneResult(result), retained !== void 0 && consecutive && !delivery.initial ? { ...delivery, retained } : delivery);
535
538
  subscriber.seenDelivery = group.deliveries;
536
539
  }
537
540
  #deliverInvalidation(subscriber, invalidation) {
@@ -569,10 +572,10 @@ class LiveQuerySet {
569
572
  continue;
570
573
  subscriber.closed = true;
571
574
  this.#subscriptionCount -= 1;
572
- subscriber.options.onComplete?.();
575
+ callLiveCallback(() => subscriber.options.onComplete?.());
573
576
  }
574
577
  }
575
- this.#onClosed?.();
578
+ callLiveCallback(this.#onClosed);
576
579
  }
577
580
  #hint() {
578
581
  if (this.#closed)
@@ -735,23 +738,23 @@ class LiveQuerySet {
735
738
  if (isObserver(subscriber)) {
736
739
  if (execution !== void 0 && !execution.changed && subscriber.options.suppressUnchanged === true) {
737
740
  this.#stats.notificationsSuppressed += 1;
738
- return;
741
+ continue;
739
742
  }
740
743
  try {
741
744
  this.#deliverInvalidation(subscriber, invalidation);
742
745
  this.#stats.invalidations += 1;
743
746
  } catch (error) {
744
- subscriber.options.onError?.(error);
747
+ callLiveCallback(() => subscriber.options.onError?.(error));
745
748
  }
746
749
  } else if (isResultSubscriber(subscriber) && execution !== void 0) {
747
750
  if (!execution.changed) {
748
751
  this.#stats.notificationsSuppressed += 1;
749
- return;
752
+ continue;
750
753
  }
751
754
  try {
752
755
  this.#deliverResult(group, subscriber, execution.result, invalidation, execution.retained);
753
756
  } catch (error) {
754
- subscriber.options.onError?.(error);
757
+ callLiveCallback(() => subscriber.options.onError?.(error));
755
758
  }
756
759
  }
757
760
  }
@@ -769,7 +772,7 @@ class LiveQuerySet {
769
772
  for (const subscriber of group.subscribers) {
770
773
  if (subscriber.closed)
771
774
  continue;
772
- subscriber.options.onError?.(error);
775
+ callLiveCallback(() => subscriber.options.onError?.(error));
773
776
  }
774
777
  }
775
778
  async #changedTablesSince(after, until) {
@@ -1,3 +1,4 @@
1
+ import { encodeQueryIdentity } from "./query-identity.js";
1
2
  import { dateIsoString, dateMilliseconds } from "../date-value.js";
2
3
  import { crossJoinPlan } from "../plan/model.js";
3
4
  import { blockHasRowWindow, blockHasSubqueries, childExpressions, DUAL_TABLE, expressionAliases, forEachBlockExpression, forEachNestedBlock, hasAggregate, isAggregateCall, isScalarFunctionName, mapBlockExpressions, mapChildExpressions, parseQuantified, scalarFunctionNames, scalarFunctionValue, splitCondition, statementDatetimeNames, volatileScalarFunctionNames, transparentProjectionSource, dateTruncValue, integerQuotient } from "./query.js";
@@ -557,7 +558,7 @@ function propagateJoinKeyConstants(block) {
557
558
  }
558
559
  if (pairs.length === 0)
559
560
  return;
560
- const signature = (predicate) => JSON.stringify(predicate);
561
+ const signature = (predicate) => encodeQueryIdentity(predicate);
561
562
  const present = new Set(block.predicates.map(signature));
562
563
  const implied = [];
563
564
  const constantSide = (predicate) => {
@@ -785,9 +786,9 @@ function decorrelateBlock(block, nextAlias) {
785
786
  if (!expressionHasCorrelatedSubquery(item.expression))
786
787
  continue;
787
788
  if (grouped) {
788
- const groupedExpressions = new Set(block.groupBy.map((group) => JSON.stringify(group)));
789
+ const groupedExpressions = new Set(block.groupBy.map((group) => encodeQueryIdentity(group)));
789
790
  const outerReferences = correlatedOuterReferences(item.expression);
790
- if (containsAggregateCall(item.expression) || outerReferences.some((reference) => !groupedExpressions.has(JSON.stringify({ kind: "column", reference })))) {
791
+ if (containsAggregateCall(item.expression) || outerReferences.some((reference) => !groupedExpressions.has(encodeQueryIdentity({ kind: "column", reference })))) {
791
792
  throw new TypeError("A grouped correlated select-list subquery must reference only GROUP BY columns and cannot be nested inside an aggregate");
792
793
  }
793
794
  }
@@ -2428,7 +2429,11 @@ function foldExpression(expression) {
2428
2429
  if (folded === null || typeof folded === "string" || typeof folded === "boolean" || folded instanceof Date || typeof folded === "number" && Number.isFinite(folded)) {
2429
2430
  const target = foldedArguments[1];
2430
2431
  const targetWord = expression.name === "CAST" && target?.kind === "literal" && typeof target.value === "string" ? target.value : void 0;
2431
- const sqlDomain = targetWord?.startsWith("numeric:") === true ? (() => {
2432
+ const first = literalValues[0];
2433
+ const sqlDomain = expression.name === "ARRAY" ? {
2434
+ kind: "array",
2435
+ element: typeof first === "number" ? "DOUBLE" : typeof first === "boolean" ? "BOOLEAN" : first instanceof Date ? "TIMESTAMP" : "TEXT"
2436
+ } : targetWord?.startsWith("numeric:") === true ? (() => {
2432
2437
  const [, precisionWord = "", scaleWord = ""] = targetWord.split(":");
2433
2438
  return {
2434
2439
  kind: "numeric",
@@ -2565,8 +2570,8 @@ function rewriteForInner(expression, source, derived, singleSource) {
2565
2570
  if (containsAggregateOrWindow(item.expression))
2566
2571
  return void 0;
2567
2572
  if (derived.groupBy.length > 0) {
2568
- const signature = JSON.stringify(item.expression);
2569
- if (!derived.groupBy.some((group) => JSON.stringify(group) === signature)) {
2573
+ const signature = encodeQueryIdentity(item.expression);
2574
+ if (!derived.groupBy.some((group) => encodeQueryIdentity(group) === signature)) {
2570
2575
  return void 0;
2571
2576
  }
2572
2577
  }
@@ -1,3 +1,4 @@
1
+ import { encodeQueryIdentity } from "./query-identity.js";
1
2
  import { copyDate, dateMilliseconds } from "../date-value.js";
2
3
  import { estimateValuesBytes } from "./byte-estimates.js";
3
4
  import { copyQueryResultExternalization } from "./query.js";
@@ -7,19 +8,7 @@ function queryResultMemoKey(sql, params) {
7
8
  return JSON.stringify([sql, params.map(encodeParameter)]);
8
9
  }
9
10
  function planMemoKey(plan) {
10
- return JSON.stringify(plan, (_key, value) => {
11
- if (value instanceof Date)
12
- return { $date: dateMilliseconds(value) };
13
- if (typeof value === "bigint")
14
- return { $bigint: value.toString() };
15
- if (typeof value === "number") {
16
- if (Object.is(value, -0))
17
- return { $number: "-0" };
18
- if (!Number.isFinite(value))
19
- return { $number: String(value) };
20
- }
21
- return value;
22
- });
11
+ return encodeQueryIdentity(plan);
23
12
  }
24
13
  function copyQueryResult(result) {
25
14
  const columns = result.columns;
@@ -0,0 +1,8 @@
1
+ import type { Manifest, StoragePage } from "../storage/types.js";
2
+ /** Bounded, process-local table generations proved by a contiguous durable commit history. */
3
+ export declare class QueryGenerations {
4
+ #private;
5
+ readonly page: (after: number | null, limit: number) => Promise<StoragePage<Manifest, number>>;
6
+ constructor(page: (after: number | null, limit: number) => Promise<StoragePage<Manifest, number>>);
7
+ key(tableIds: readonly string[], version: number | null): Promise<string>;
8
+ }