@minnowdb/core 0.7.8 → 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.
- package/dist/engine/client.d.ts +5 -2
- package/dist/engine/client.js +14 -4
- package/dist/engine/database.js +632 -39
- package/dist/engine/errors.d.ts +2 -2
- package/dist/engine/errors.js +1 -1
- package/dist/engine/keyed-live.js +55 -18
- package/dist/engine/live-aggregate.d.ts +12 -0
- package/dist/engine/live-aggregate.js +226 -0
- package/dist/engine/live-patch.d.ts +21 -0
- package/dist/engine/live-patch.js +31 -0
- package/dist/engine/live.d.ts +107 -8
- package/dist/engine/live.js +353 -187
- package/dist/engine/optimizer.js +11 -6
- package/dist/engine/query-cache.js +2 -13
- package/dist/engine/query-generations.d.ts +8 -0
- package/dist/engine/query-generations.js +61 -0
- package/dist/engine/query-identity.d.ts +3 -0
- package/dist/engine/query-identity.js +41 -0
- package/dist/engine/query.d.ts +6 -11
- package/dist/engine/query.js +294 -428
- package/dist/engine/sql-domains.d.ts +4 -0
- package/dist/engine/sql-domains.js +121 -0
- package/dist/engine/sql-semantics.js +7 -4
- package/dist/engine/typed-live.d.ts +12 -1
- package/dist/engine/typed-live.js +110 -25
- package/dist/engine/windows.d.ts +12 -0
- package/dist/engine/windows.js +386 -0
- package/dist/engine/worker-server.js +12 -5
- package/dist/plan/model.d.ts +1 -1
- package/dist/worker-protocol/index.d.ts +1 -1
- package/dist/worker-protocol/index.js +1 -1
- package/package.json +1 -1
- package/postgres-feature-profile.json +6 -1
- package/sql-feature-matrix.json +20 -27
package/dist/engine/errors.d.ts
CHANGED
|
@@ -17,10 +17,10 @@ export declare class DatabaseReadBacklogError extends Error {
|
|
|
17
17
|
}
|
|
18
18
|
/** A live-query owner reached one of its documented resident-resource ceilings. */
|
|
19
19
|
export declare class LiveQueryLimitError extends Error {
|
|
20
|
-
readonly resource: "set" | "group" | "subscription";
|
|
20
|
+
readonly resource: "set" | "group" | "subscription" | "byte";
|
|
21
21
|
readonly limit: number;
|
|
22
22
|
readonly name = "LiveQueryLimitError";
|
|
23
|
-
constructor(resource: "set" | "group" | "subscription", limit: number);
|
|
23
|
+
constructor(resource: "set" | "group" | "subscription" | "byte", limit: number);
|
|
24
24
|
}
|
|
25
25
|
export declare class UniqueConstraintError extends Error {
|
|
26
26
|
readonly tableName: string;
|
package/dist/engine/errors.js
CHANGED
|
@@ -22,7 +22,7 @@ class LiveQueryLimitError extends Error {
|
|
|
22
22
|
limit;
|
|
23
23
|
name = "LiveQueryLimitError";
|
|
24
24
|
constructor(resource, limit) {
|
|
25
|
-
super(resource === "set" ? `A database cannot retain more than ${String(limit)} live-query sets` : `A live-query set cannot retain more than ${String(limit)} ${resource} records`);
|
|
25
|
+
super(resource === "set" ? `A database cannot retain more than ${String(limit)} live-query sets` : resource === "byte" ? `A live-query set cannot retain more than ${String(limit)} modeled bytes` : `A live-query set cannot retain more than ${String(limit)} ${resource} records`);
|
|
26
26
|
this.resource = resource;
|
|
27
27
|
this.limit = limit;
|
|
28
28
|
}
|
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
import { dateMilliseconds } from "../date-value.js";
|
|
2
2
|
import { sameLiveValue } from "./live-equal.js";
|
|
3
|
+
const dateTokens = /* @__PURE__ */ new Map();
|
|
3
4
|
function keyToken(value, name) {
|
|
4
|
-
if (typeof value === "string")
|
|
5
|
-
return
|
|
6
|
-
if (typeof value === "boolean")
|
|
7
|
-
return value ? "b:1" : "b:0";
|
|
5
|
+
if (typeof value === "string" || typeof value === "boolean")
|
|
6
|
+
return value;
|
|
8
7
|
if (typeof value === "number") {
|
|
9
8
|
if (Number.isNaN(value))
|
|
10
|
-
return
|
|
9
|
+
return NaN;
|
|
11
10
|
if (Object.is(value, -0))
|
|
12
|
-
return "
|
|
13
|
-
return
|
|
11
|
+
return /* @__PURE__ */ Symbol.for("minnow.live.key.-0");
|
|
12
|
+
return value;
|
|
14
13
|
}
|
|
15
14
|
if (value instanceof Date) {
|
|
16
15
|
const time = dateMilliseconds(value);
|
|
17
16
|
if (!Number.isFinite(time)) {
|
|
18
17
|
throw new TypeError(`Live query key ${String(name)} must be a valid Date`);
|
|
19
18
|
}
|
|
20
|
-
|
|
19
|
+
let token = dateTokens.get(time);
|
|
20
|
+
if (token === void 0) {
|
|
21
|
+
token = /* @__PURE__ */ Symbol(`minnow.live.key.date:${String(time)}`);
|
|
22
|
+
dateTokens.set(time, token);
|
|
23
|
+
if (dateTokens.size > 65536)
|
|
24
|
+
dateTokens.clear();
|
|
25
|
+
}
|
|
26
|
+
return token;
|
|
21
27
|
}
|
|
22
28
|
throw new TypeError(`Live query key ${String(name)} must be a non-null string, number, boolean, or Date`);
|
|
23
29
|
}
|
|
@@ -35,8 +41,7 @@ function indexRows(rows, key) {
|
|
|
35
41
|
}
|
|
36
42
|
return indexed;
|
|
37
43
|
}
|
|
38
|
-
function diffRows(previousRows, rows, key) {
|
|
39
|
-
const previous = indexRows(previousRows, key);
|
|
44
|
+
function diffRows(previous, previousRows, rows, key) {
|
|
40
45
|
const current = indexRows(rows, key);
|
|
41
46
|
const changes = [];
|
|
42
47
|
for (const [token, old] of previous) {
|
|
@@ -44,26 +49,44 @@ function diffRows(previousRows, rows, key) {
|
|
|
44
49
|
continue;
|
|
45
50
|
changes.push({ type: "delete", key: old.row[key], previous: old.row, index: old.index });
|
|
46
51
|
}
|
|
52
|
+
let reused = 0;
|
|
53
|
+
const reconciled = new Array(rows.length);
|
|
47
54
|
for (const [token, next] of current) {
|
|
48
55
|
const old = previous.get(token);
|
|
49
56
|
if (old === void 0) {
|
|
57
|
+
reconciled[next.index] = next.row;
|
|
50
58
|
changes.push({ type: "insert", row: next.row, index: next.index });
|
|
51
59
|
continue;
|
|
52
60
|
}
|
|
53
|
-
|
|
61
|
+
let kept = next.row;
|
|
62
|
+
if (old.row === next.row || sameLiveValue(old.row, next.row)) {
|
|
63
|
+
kept = old.row;
|
|
64
|
+
if (old.row !== next.row) {
|
|
65
|
+
current.set(token, { row: old.row, index: next.index });
|
|
66
|
+
reused += 1;
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
54
69
|
changes.push({ type: "update", row: next.row, previous: old.row, index: next.index });
|
|
55
70
|
}
|
|
71
|
+
reconciled[next.index] = kept;
|
|
56
72
|
if (old.index !== next.index) {
|
|
57
73
|
changes.push({
|
|
58
74
|
type: "move",
|
|
59
75
|
key: next.row[key],
|
|
60
|
-
row:
|
|
76
|
+
row: kept,
|
|
61
77
|
from: old.index,
|
|
62
78
|
to: next.index
|
|
63
79
|
});
|
|
64
80
|
}
|
|
65
81
|
}
|
|
66
|
-
|
|
82
|
+
if (changes.length === 0 && rows.length === previousRows.length) {
|
|
83
|
+
return { changes, rows: previousRows, index: current };
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
changes,
|
|
87
|
+
rows: reused === 0 ? rows : Object.freeze(reconciled),
|
|
88
|
+
index: current
|
|
89
|
+
};
|
|
67
90
|
}
|
|
68
91
|
class KeyedLiveQuery {
|
|
69
92
|
#source;
|
|
@@ -72,6 +95,8 @@ class KeyedLiveQuery {
|
|
|
72
95
|
#listeners = /* @__PURE__ */ new Set();
|
|
73
96
|
#sourceUnsubscribe;
|
|
74
97
|
#rows = [];
|
|
98
|
+
#sourceRows;
|
|
99
|
+
#index;
|
|
75
100
|
#snapshot = { status: "loading", rows: [] };
|
|
76
101
|
#hasReadySnapshot = false;
|
|
77
102
|
#closed = false;
|
|
@@ -141,23 +166,35 @@ class KeyedLiveQuery {
|
|
|
141
166
|
this.#emit();
|
|
142
167
|
return;
|
|
143
168
|
}
|
|
169
|
+
if (this.#snapshot.status === "ready" && snapshot.rows === this.#sourceRows && this.#snapshot.version === snapshot.version) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
144
172
|
try {
|
|
145
173
|
if (this.#maxRows !== void 0 && snapshot.rows.length > this.#maxRows) {
|
|
146
174
|
throw new RangeError(`Live query window returned ${String(snapshot.rows.length)} rows; maximum is ${String(this.#maxRows)}`);
|
|
147
175
|
}
|
|
148
176
|
const initial = !this.#hasReadySnapshot;
|
|
149
177
|
let changes;
|
|
150
|
-
|
|
151
|
-
|
|
178
|
+
let rows;
|
|
179
|
+
if (initial || this.#index === void 0) {
|
|
180
|
+
this.#index = indexRows(snapshot.rows, this.#key);
|
|
181
|
+
rows = snapshot.rows;
|
|
152
182
|
changes = snapshot.rows.map((row, index) => ({ type: "insert", row, index }));
|
|
183
|
+
} else if (snapshot.rows === this.#sourceRows) {
|
|
184
|
+
rows = this.#rows;
|
|
185
|
+
changes = [];
|
|
153
186
|
} else {
|
|
154
|
-
|
|
187
|
+
const diff = diffRows(this.#index, this.#rows, snapshot.rows, this.#key);
|
|
188
|
+
this.#index = diff.index;
|
|
189
|
+
rows = diff.rows;
|
|
190
|
+
changes = diff.changes;
|
|
155
191
|
}
|
|
156
|
-
this.#rows =
|
|
192
|
+
this.#rows = rows;
|
|
193
|
+
this.#sourceRows = snapshot.rows;
|
|
157
194
|
this.#hasReadySnapshot = true;
|
|
158
195
|
this.#snapshot = {
|
|
159
196
|
status: "ready",
|
|
160
|
-
rows
|
|
197
|
+
rows,
|
|
161
198
|
changes: Object.freeze(changes),
|
|
162
199
|
initial,
|
|
163
200
|
version: snapshot.version
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CompiledQuery, QueryResult, QueryValue } from "../plan/model.js";
|
|
2
|
+
/** Single-table COUNT/SUM/AVG contributions; SQL still evaluates filters and arguments. */
|
|
3
|
+
export declare class LiveAggregate {
|
|
4
|
+
#private;
|
|
5
|
+
readonly inputPlan: CompiledQuery;
|
|
6
|
+
readonly keyAlias: string;
|
|
7
|
+
private constructor();
|
|
8
|
+
static plan(plan: CompiledQuery, qualifiedKey: string): LiveAggregate | undefined;
|
|
9
|
+
patch(result: QueryResult, changed: ReadonlySet<string>, token: (value: QueryValue) => string): LiveAggregate;
|
|
10
|
+
result(): QueryResult;
|
|
11
|
+
get retainedBytes(): number;
|
|
12
|
+
}
|
|
@@ -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
|
+
};
|
package/dist/engine/live.d.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -6,8 +8,11 @@ import { type CompiledQuery, type QueryResult, type QueryValue } from "./query.j
|
|
|
6
8
|
* table sets then decide which prepared queries may be stale. Hints may be lost, duplicated, or
|
|
7
9
|
* reordered without changing correctness.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
+
* A sweep costs what changed, not what is subscribed: the commit window's table set selects the
|
|
12
|
+
* groups to visit through a per-table index, and a group nobody visits keeps its result on the
|
|
13
|
+
* strength of the invariant that every window touching one of its tables would have visited it.
|
|
14
|
+
* Equal statements share one dependency record and one execution per sweep. Results are compared
|
|
15
|
+
* exactly, row by row, so an unchanged result never reaches a subscriber.
|
|
11
16
|
*/
|
|
12
17
|
export interface LiveQueryHintChannel {
|
|
13
18
|
postMessage(message: unknown): void;
|
|
@@ -24,6 +29,22 @@ export interface LiveQuerySetOptions {
|
|
|
24
29
|
readonly maxGroups?: number;
|
|
25
30
|
/** Maximum result/observer subscriptions retained by this set. Defaults to 1,024. */
|
|
26
31
|
readonly maxSubscriptions?: number;
|
|
32
|
+
/** Maximum modeled resident result and maintenance bytes per set. Defaults to 64 MiB. */
|
|
33
|
+
readonly maxRetainedBytes?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Hand `onChange` the set's retained result instead of a private copy. The result is shared
|
|
36
|
+
* with every equal subscription and with the next change comparison, so a subscriber must
|
|
37
|
+
* treat it as read-only. A consumer that only reads it synchronously — the worker host that
|
|
38
|
+
* encodes it for the channel, a renderer that copies what it displays — saves one full copy
|
|
39
|
+
* per subscriber per change.
|
|
40
|
+
*/
|
|
41
|
+
readonly sharedResults?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Whether the set may patch a retained result from a commit's rows instead of re-running the
|
|
44
|
+
* statement, for statements the host can maintain that way. Defaults to true; false makes
|
|
45
|
+
* every relevant commit a full execution, which is useful when comparing the two.
|
|
46
|
+
*/
|
|
47
|
+
readonly incremental?: boolean;
|
|
27
48
|
/** Called once when the set closes; the owner uses this to drop its reference. */
|
|
28
49
|
readonly onClosed?: () => void;
|
|
29
50
|
}
|
|
@@ -33,8 +54,23 @@ export declare const MAX_LIVE_QUERY_GROUPS = 4096;
|
|
|
33
54
|
export declare const MAX_LIVE_QUERY_SUBSCRIPTIONS = 16384;
|
|
34
55
|
export declare const MAX_LIVE_QUERY_SETS_PER_DATABASE = 256;
|
|
35
56
|
export { LiveQueryLimitError } from "./errors.js";
|
|
57
|
+
/** What a delivered result reflects: the probe it is current as of, and whether it is the first. */
|
|
58
|
+
export interface LiveQueryDelivery {
|
|
59
|
+
readonly manifestVersion: number | null;
|
|
60
|
+
readonly catalogEpoch: number;
|
|
61
|
+
readonly initial: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* For each row, the index it held in this subscription's previous delivery, or -1 for a row
|
|
64
|
+
* that is new or changed. Present when the engine kept row objects across the change — a
|
|
65
|
+
* patched result keeps every untouched row — and absent after a full execution or on the
|
|
66
|
+
* first delivery. A consumer that keys on row identity substitutes its own previous objects.
|
|
67
|
+
*/
|
|
68
|
+
readonly retained?: Int32Array;
|
|
69
|
+
}
|
|
36
70
|
export interface LiveQuerySubscribeOptions {
|
|
37
|
-
|
|
71
|
+
/** Borrow the read-only retained result for this subscription instead of copying it. */
|
|
72
|
+
readonly sharedResults?: boolean;
|
|
73
|
+
onChange(result: QueryResult, delivery: LiveQueryDelivery): void;
|
|
38
74
|
onError?(error: unknown): void;
|
|
39
75
|
/** Called once when the subscription ends because the subscription or its set closed. */
|
|
40
76
|
onComplete?(): void;
|
|
@@ -48,6 +84,14 @@ export interface LiveQueryObserveOptions {
|
|
|
48
84
|
onInvalidate(invalidation: LiveQueryInvalidation): void;
|
|
49
85
|
onError?(error: unknown): void;
|
|
50
86
|
onComplete?(): void;
|
|
87
|
+
/**
|
|
88
|
+
* Execute the statement inside the set on every relevant commit and invalidate only when the
|
|
89
|
+
* rows changed. The engine keeps that execution in its result memo, so an adapter that then
|
|
90
|
+
* re-executes the same statement at the same version is served from cache rather than from a
|
|
91
|
+
* second scan. A commit that leaves the rows as they were costs one execution and reaches no
|
|
92
|
+
* observer at all — nothing crosses a worker channel and nothing re-renders.
|
|
93
|
+
*/
|
|
94
|
+
readonly suppressUnchanged?: boolean;
|
|
51
95
|
}
|
|
52
96
|
export interface LiveQuerySubscription {
|
|
53
97
|
readonly dependencyTableIds: readonly string[];
|
|
@@ -58,12 +102,26 @@ export interface LiveQueryStats {
|
|
|
58
102
|
versionChecks: number;
|
|
59
103
|
sweeps: number;
|
|
60
104
|
reruns: number;
|
|
105
|
+
/** Subscribed groups a sweep did not re-run: nothing they read changed, or a proof said so. */
|
|
61
106
|
rerunsAvoided: number;
|
|
62
107
|
/** Re-runs skipped because the data layer proved the commits could not change the result. */
|
|
63
108
|
zoneSkips: number;
|
|
109
|
+
/** Deliveries withheld because an execution produced exactly the rows already delivered. */
|
|
64
110
|
notificationsSuppressed: number;
|
|
65
|
-
/** Observer
|
|
111
|
+
/** Observer invalidations delivered. */
|
|
66
112
|
invalidations: number;
|
|
113
|
+
/** Re-runs answered by patching the retained result with the commit's rows instead. */
|
|
114
|
+
maintained: number;
|
|
115
|
+
/** Groups a sweep looked at: those whose tables the commits changed, plus any left lagging. */
|
|
116
|
+
groupsVisited: number;
|
|
117
|
+
/**
|
|
118
|
+
* Rows the set currently retains across every group's last result — what its subscriptions
|
|
119
|
+
* display, counted once per distinct statement. A window's margin beyond its visible rows is
|
|
120
|
+
* bounded by the engine at 64 rows and is not included.
|
|
121
|
+
*/
|
|
122
|
+
retainedRows: number;
|
|
123
|
+
/** Modeled resident result and incremental maintenance bytes, counted once per group. */
|
|
124
|
+
retainedBytes: number;
|
|
67
125
|
/** Work avoided because equal statements shared one query group or in-flight execution. */
|
|
68
126
|
sharedExecutions: number;
|
|
69
127
|
lastSweepMs: number;
|
|
@@ -77,11 +135,50 @@ export type LiveQueryInput = string | {
|
|
|
77
135
|
kind: "typed-query";
|
|
78
136
|
plan: CompiledQuery;
|
|
79
137
|
};
|
|
80
|
-
|
|
138
|
+
/** What the set already knows when it asks the host to execute a statement. */
|
|
139
|
+
export interface LiveQueryExecuteContext {
|
|
140
|
+
/**
|
|
141
|
+
* A freshness probe the set read moments ago. The host may start execution from it instead of
|
|
142
|
+
* reading its own; a result may still observe a newer commit, and the set treats the probe as
|
|
143
|
+
* a lower bound on what the result reflects.
|
|
144
|
+
*/
|
|
145
|
+
readonly probe: CatalogProbe;
|
|
146
|
+
/**
|
|
147
|
+
* Whether the host should keep the result in its memo. The set retains its own copy, so a
|
|
148
|
+
* memo entry only pays off when another caller — an adapter re-executing after an
|
|
149
|
+
* invalidation — will ask for the same statement at the same version.
|
|
150
|
+
*/
|
|
151
|
+
readonly memoize: boolean;
|
|
152
|
+
}
|
|
153
|
+
/** A maintainable statement's execution: its result and the host's opaque state for patching it. */
|
|
154
|
+
export interface LiveMaintainedExecution {
|
|
155
|
+
readonly result: QueryResult;
|
|
156
|
+
readonly state: unknown;
|
|
157
|
+
/** Modeled total bytes retained by the result and opaque maintenance state. */
|
|
158
|
+
readonly retainedBytes?: number;
|
|
159
|
+
}
|
|
160
|
+
export interface LiveMaintainedChange extends LiveMaintainedExecution {
|
|
161
|
+
readonly changed: boolean;
|
|
162
|
+
/** For each row of `result`, its index in the previous result, or -1; see `LiveQueryDelivery`. */
|
|
163
|
+
readonly retained?: Int32Array;
|
|
164
|
+
}
|
|
165
|
+
export interface LiveQueryHost {
|
|
81
166
|
currentProbe(): Promise<CatalogProbe>;
|
|
82
167
|
manifestPage(afterVersion: number | null, limit: number): Promise<StoragePage<Manifest, number>>;
|
|
83
|
-
|
|
84
|
-
|
|
168
|
+
/** The base tables the statement reads, resolved through views; `probe` is a recent read. */
|
|
169
|
+
dependencyTableIds(query: LiveQueryInput, probe?: CatalogProbe): Promise<Set<string>>;
|
|
170
|
+
/** Executes the statement; the returned result belongs to the set and is never shared. */
|
|
171
|
+
execute(query: LiveQueryInput, context?: LiveQueryExecuteContext): Promise<QueryResult>;
|
|
172
|
+
/**
|
|
173
|
+
* Executes a statement the host can later maintain incrementally, or returns undefined when
|
|
174
|
+
* the statement's shape rules that out; the set then executes it in full from then on.
|
|
175
|
+
*/
|
|
176
|
+
executeMaintainable?(query: LiveQueryInput, context?: LiveQueryExecuteContext): Promise<LiveMaintainedExecution | undefined>;
|
|
177
|
+
/**
|
|
178
|
+
* Patches a retained result with the row changes the commits in (after, until] made to
|
|
179
|
+
* `tableIds`, or returns undefined when only a full execution can answer.
|
|
180
|
+
*/
|
|
181
|
+
maintain?(query: LiveQueryInput, result: QueryResult, state: unknown, tableIds: readonly string[], after: number | null, until: number, probe: CatalogProbe): Promise<LiveMaintainedChange | undefined>;
|
|
85
182
|
/** Returns false only on proof that the commit window cannot affect the statement. */
|
|
86
183
|
changeCanAffect?(query: LiveQueryInput, tableIds: readonly string[], after: number | null, until: number): Promise<boolean>;
|
|
87
184
|
}
|
|
@@ -91,7 +188,9 @@ export declare class LiveQuerySet {
|
|
|
91
188
|
get stats(): LiveQueryStats;
|
|
92
189
|
/** Registers a query, delivers its current result, and shares work with equal statements. */
|
|
93
190
|
subscribe(query: LiveQueryInput, options: LiveQuerySubscribeOptions): Promise<LiveQuerySubscription>;
|
|
94
|
-
/**
|
|
191
|
+
/** Delivers resets or changed row payloads without constructing a private full row array per patch. */
|
|
192
|
+
subscribePatches(query: LiveQueryInput, options: LiveQueryPatchOptions): Promise<LiveQuerySubscription>;
|
|
193
|
+
/** Observes invalidation; the statement executes inside the set only when asked to compare. */
|
|
95
194
|
observe(query: LiveQueryInput, options: LiveQueryObserveOptions): Promise<LiveQuerySubscription>;
|
|
96
195
|
/** Called by the owning database after each local write commit; also hints other tabs. */
|
|
97
196
|
notifyLocalCommit(): void;
|