@protobase/server 0.55.9 → 0.55.10
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/package.js +1 -1
- package/dist/realtime/bounded-runner.js +31 -0
- package/dist/realtime/change-checker.js +79 -32
- package/dist/realtime/change-filter.js +6 -2
- package/dist/realtime/change-hub.js +28 -11
- package/dist/realtime/filter-check.js +70 -0
- package/dist/realtime/filter-chunks.js +35 -0
- package/dist/realtime/filter-evaluation.js +27 -0
- package/dist/realtime/realtime-app.js +6 -0
- package/dist/realtime/realtime-routes.js +2 -1
- package/dist/realtime/realtime-socket.js +14 -2
- package/dist/realtime/savepoint-attempt.js +39 -0
- package/dist/transactions.js +6 -1
- package/package.json +4 -4
package/dist/package.js
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/realtime/bounded-runner.ts
|
|
2
|
+
/**
|
|
3
|
+
* Runs at most `concurrent` tasks at once, with at most `waiting` more in line; a task beyond those is refused rather
|
|
4
|
+
* than queued, so the work never holds more than `concurrent` of what it uses, such as pooled connections.
|
|
5
|
+
*/
|
|
6
|
+
const boundedRunner = (concurrent, waiting) => {
|
|
7
|
+
let running = 0;
|
|
8
|
+
const line = [];
|
|
9
|
+
const next = () => {
|
|
10
|
+
running -= 1;
|
|
11
|
+
line.shift()?.();
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
/** The task's result, or `refused` when the line is full. */
|
|
15
|
+
run: async (task) => {
|
|
16
|
+
if (running >= concurrent) {
|
|
17
|
+
if (line.length >= waiting) return { refused: true };
|
|
18
|
+
await new Promise((resolve) => line.push(resolve));
|
|
19
|
+
}
|
|
20
|
+
running += 1;
|
|
21
|
+
return task().then((value) => (next(), {
|
|
22
|
+
refused: false,
|
|
23
|
+
value
|
|
24
|
+
}), (error) => {
|
|
25
|
+
next();
|
|
26
|
+
throw error;
|
|
27
|
+
});
|
|
28
|
+
} };
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { boundedRunner };
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { snapshotReadTransaction } from "../transactions.js";
|
|
2
2
|
import { quoteIdent, tableSql } from "../changes/capture-sql.js";
|
|
3
3
|
import { callerSettings } from "../rest/caller-settings.js";
|
|
4
4
|
import { filterSql } from "./change-filter.js";
|
|
5
|
+
import { filterChunks } from "./filter-chunks.js";
|
|
6
|
+
import { evaluateFilters } from "./filter-evaluation.js";
|
|
7
|
+
import { attempt } from "./savepoint-attempt.js";
|
|
5
8
|
import { decodeKey, encodeKey } from "@protobase/schema/internal";
|
|
6
9
|
import { sql } from "kysely";
|
|
7
10
|
//#region src/realtime/change-checker.ts
|
|
@@ -16,18 +19,35 @@ const keyRows = (entry, keys) => {
|
|
|
16
19
|
};
|
|
17
20
|
const listed = (alias, columns) => sql.raw(columns.map((column) => `${alias}.${quoteIdent(column)}`).join(", "));
|
|
18
21
|
const keyTexts = (alias, entry) => sql.raw(keyColumns(entry).map((column, index) => `${alias}.${quoteIdent(column)}::text as "k${index}"`).join(", "));
|
|
19
|
-
const filterColumns = (alias, filters) =>
|
|
20
|
-
const
|
|
22
|
+
const filterColumns = (alias, filters, indexes) => indexes.length === 0 ? sql`` : sql`, ${sql.join(indexes.map((index) => sql`${filterSql(alias, filters[index])} as ${sql.ref(`f${index}`)}`))}`;
|
|
23
|
+
const keyOf = (entry, row) => encodeKey(keyColumns(entry).map((_, index) => row[`k${index}`]));
|
|
24
|
+
const seen = (entry, filters, rows) => new Map(rows.map((row) => [keyOf(entry, row), {
|
|
21
25
|
record: row.record ?? {},
|
|
22
26
|
oldRecord: row.old_record,
|
|
23
|
-
matches: filters.map((
|
|
27
|
+
matches: filters.map(() => false)
|
|
24
28
|
}]));
|
|
29
|
+
const merge = (entry, into, rows, indexes) => {
|
|
30
|
+
for (const row of rows) {
|
|
31
|
+
const found = into.get(keyOf(entry, row));
|
|
32
|
+
if (found) for (const index of indexes) found.matches[index] = row[`f${index}`] === true;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const nothing = (columns = []) => ({
|
|
36
|
+
columns,
|
|
37
|
+
rows: /* @__PURE__ */ new Map(),
|
|
38
|
+
deleted: /* @__PURE__ */ new Map(),
|
|
39
|
+
failed: /* @__PURE__ */ new Set()
|
|
40
|
+
});
|
|
25
41
|
/**
|
|
26
|
-
* Checks which changes callers may see, for every group in one read-only transaction
|
|
27
|
-
* claims as `/api/sb/rest/v1` does, then reads the current rows by key, so the table's RLS policies
|
|
28
|
-
* each filter in Postgres, as the column's type compares
|
|
29
|
-
*
|
|
30
|
-
*
|
|
42
|
+
* Checks which changes callers may see, for every group in one read-only transaction that reads one snapshot: per group
|
|
43
|
+
* it sets the role and claims as `/api/sb/rest/v1` does, then reads the current rows by key, so the table's RLS policies
|
|
44
|
+
* decide, and evaluates each filter in Postgres, as the column's type compares, at most `filterChunkLimits` filters per
|
|
45
|
+
* query. The transaction holds one pooled connection only while it runs.
|
|
46
|
+
*
|
|
47
|
+
* Each query runs in a savepoint. A resource whose rows cannot be read even without filters, such as after a REVOKE,
|
|
48
|
+
* shows its group nothing of that resource; a filter Postgres refuses fails alone (`ResourceView.failed`), found by
|
|
49
|
+
* halving its chunk. Any other error, such as a lost connection, fails the transaction: each group is then checked
|
|
50
|
+
* again in a transaction of its own, so it costs only its own callers the batch.
|
|
31
51
|
*/
|
|
32
52
|
const changeChecker = ({ db, statementTimeoutMs, report }) => {
|
|
33
53
|
const columnCache = /* @__PURE__ */ new Map();
|
|
@@ -40,28 +60,59 @@ const changeChecker = ({ db, statementTimeoutMs, report }) => {
|
|
|
40
60
|
columnCache.set(`${entry.name}\0${role}`, rows);
|
|
41
61
|
return rows;
|
|
42
62
|
};
|
|
43
|
-
const
|
|
44
|
-
const columns = await columnsOf(trx, check.entry, role);
|
|
45
|
-
const names = columns.map((column) => column.name);
|
|
46
|
-
if (!keyColumns(check.entry).every((column) => names.includes(column))) return {
|
|
47
|
-
columns,
|
|
48
|
-
rows: /* @__PURE__ */ new Map(),
|
|
49
|
-
deleted: /* @__PURE__ */ new Map()
|
|
50
|
-
};
|
|
63
|
+
const read = async (trx, names, check, indexes) => {
|
|
51
64
|
const table = sql.raw(tableSql(check.entry.model));
|
|
52
65
|
const key = listed("t", keyColumns(check.entry));
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
return {
|
|
67
|
+
rows: check.keys.length === 0 ? [] : (await sql`select ${keyTexts("t", check.entry)}, (select to_jsonb(v) from (select ${listed("t", names)}) v) as record,
|
|
68
|
+
(select to_jsonb(v) from (select ${key}) v) as old_record${filterColumns("t", check.filters, indexes)}
|
|
69
|
+
from ${table} t where (${key}) in (select ${listed("k", keyColumns(check.entry))} from jsonb_populate_recordset(null::${table}, ${keyRows(check.entry, check.keys)}::jsonb) k)`.execute(trx)).rows,
|
|
70
|
+
deleted: check.deleted.length === 0 ? [] : (await sql`select ${keyTexts("k", check.entry)}, (select to_jsonb(v) from (select ${listed("k", keyColumns(check.entry))}) v) as old_record${filterColumns("k", check.filters, indexes)}
|
|
71
|
+
from jsonb_populate_recordset(null::${table}, ${keyRows(check.entry, check.deleted)}::jsonb) k`.execute(trx)).rows
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
const view = async (trx, role, check) => {
|
|
75
|
+
const cacheKey = `${check.entry.name}\0${role}`;
|
|
76
|
+
let columns = await columnsOf(trx, check.entry, role);
|
|
77
|
+
const keyed = () => keyColumns(check.entry).every((column) => columns.some(({ name }) => name === column));
|
|
78
|
+
if (!keyed()) return nothing(columns);
|
|
79
|
+
const [first = [], ...rest] = filterChunks(check.filters);
|
|
80
|
+
let base = await attempt(trx, "change_check", () => read(trx, columns.map(({ name }) => name), check, first));
|
|
81
|
+
const failedFirst = base.ok ? void 0 : first;
|
|
82
|
+
if (!base.ok) {
|
|
83
|
+
columnCache.delete(cacheKey);
|
|
84
|
+
columns = await columnsOf(trx, check.entry, role);
|
|
85
|
+
if (!keyed()) return nothing(columns);
|
|
86
|
+
base = await attempt(trx, "change_check", () => read(trx, columns.map(({ name }) => name), check, []));
|
|
87
|
+
}
|
|
88
|
+
if (!base.ok) {
|
|
89
|
+
columnCache.delete(cacheKey);
|
|
90
|
+
report(base.error);
|
|
91
|
+
return nothing(columns);
|
|
92
|
+
}
|
|
93
|
+
const names = columns.map(({ name }) => name);
|
|
94
|
+
const rows = seen(check.entry, check.filters, base.value.rows);
|
|
95
|
+
const deleted = seen(check.entry, check.filters, base.value.deleted);
|
|
96
|
+
if (failedFirst === void 0) {
|
|
97
|
+
merge(check.entry, rows, base.value.rows, first);
|
|
98
|
+
merge(check.entry, deleted, base.value.deleted, first);
|
|
99
|
+
}
|
|
100
|
+
const failed = await evaluateFilters(rest, async (indexes) => {
|
|
101
|
+
const result = await attempt(trx, "change_check", () => read(trx, names, check, indexes));
|
|
102
|
+
if (result.ok) {
|
|
103
|
+
merge(check.entry, rows, result.value.rows, indexes);
|
|
104
|
+
merge(check.entry, deleted, result.value.deleted, indexes);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}, failedFirst);
|
|
58
108
|
return {
|
|
59
109
|
columns,
|
|
60
|
-
rows
|
|
61
|
-
deleted
|
|
110
|
+
rows,
|
|
111
|
+
deleted,
|
|
112
|
+
failed
|
|
62
113
|
};
|
|
63
114
|
};
|
|
64
|
-
const checkGroups = (groups) =>
|
|
115
|
+
const checkGroups = (groups) => snapshotReadTransaction(db, statementTimeoutMs, async (trx) => {
|
|
65
116
|
const views = [];
|
|
66
117
|
for (const group of groups) {
|
|
67
118
|
const settings = {
|
|
@@ -77,22 +128,18 @@ const changeChecker = ({ db, statementTimeoutMs, report }) => {
|
|
|
77
128
|
}
|
|
78
129
|
return views;
|
|
79
130
|
});
|
|
80
|
-
const
|
|
81
|
-
columns: [],
|
|
82
|
-
rows: /* @__PURE__ */ new Map(),
|
|
83
|
-
deleted: /* @__PURE__ */ new Map()
|
|
84
|
-
}));
|
|
131
|
+
const nothingOf = (group) => group.resources.map(() => nothing());
|
|
85
132
|
return async (groups) => {
|
|
86
133
|
if (groups.length === 0) return [];
|
|
87
134
|
return checkGroups(groups).catch(async (error) => {
|
|
88
135
|
if (groups.length === 1) {
|
|
89
136
|
report(error);
|
|
90
|
-
return [
|
|
137
|
+
return [nothingOf(groups[0])];
|
|
91
138
|
}
|
|
92
139
|
const views = [];
|
|
93
140
|
for (const group of groups) views.push(await checkGroups([group]).then((checked) => checked[0], (failed) => {
|
|
94
141
|
report(failed);
|
|
95
|
-
return
|
|
142
|
+
return nothingOf(group);
|
|
96
143
|
}));
|
|
97
144
|
return views;
|
|
98
145
|
});
|
|
@@ -79,10 +79,12 @@ const condition = (segment) => {
|
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
if (operator === "is" && !isKeyword.has(value)) return "invalid value for is filter: must be null, true, false, or unknown";
|
|
82
|
+
const unquoted = unquote(value);
|
|
83
|
+
if ((operator === "like" || operator === "ilike") && /(?:^|[^\\])(?:\\\\)*\\$/.test(unquoted)) return `invalid filter "${trimmed}": a like pattern must not end with an escape character`;
|
|
82
84
|
return {
|
|
83
85
|
column,
|
|
84
86
|
operator,
|
|
85
|
-
value:
|
|
87
|
+
value: unquoted,
|
|
86
88
|
negate
|
|
87
89
|
};
|
|
88
90
|
};
|
|
@@ -92,6 +94,8 @@ const parseFilter = (filter) => {
|
|
|
92
94
|
const parsed = segments(filter.trim()).map(condition);
|
|
93
95
|
return parsed.find((item) => typeof item === "string") ?? parsed;
|
|
94
96
|
};
|
|
97
|
+
/** The bind parameters a filter's conditions take: one each, but none for `is`. */
|
|
98
|
+
const filterParameters = (conditions) => conditions.filter((item) => item.operator !== "is").length;
|
|
95
99
|
const conditionSql = (alias, { column, operator, value, negate }) => {
|
|
96
100
|
const target = sql.raw(`${alias}.${quoteIdent(column)}`);
|
|
97
101
|
const test = operator === "is" ? sql`${target} is ${sql.raw(value)}` : operator === "in" ? sql`${target} = any(${value})` : sql`${target} ${sql.raw(operators[operator])} ${value}`;
|
|
@@ -103,4 +107,4 @@ const conditionSql = (alias, { column, operator, value, negate }) => {
|
|
|
103
107
|
*/
|
|
104
108
|
const filterSql = (alias, conditions) => conditions.length === 0 ? sql`true` : sql`coalesce(${sql.join(conditions.map((item) => conditionSql(alias, item)), sql` and `)}, false)`;
|
|
105
109
|
//#endregion
|
|
106
|
-
export { filterSql, parseFilter };
|
|
110
|
+
export { filterParameters, filterSql, parseFilter };
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { callerSettings } from "../rest/caller-settings.js";
|
|
2
2
|
import { changePayload, eventOf } from "./change-payload.js";
|
|
3
3
|
//#region src/realtime/change-hub.ts
|
|
4
|
+
/** Why a channel ends when Postgres refuses its filter at a check: it names that filter only. */
|
|
5
|
+
const failedFilterText = (filter) => `Unable to listen to changes with the filter "${filter}": the database refused it`;
|
|
4
6
|
const unique = (items) => [...new Set(items)];
|
|
5
7
|
const watches = (binding, entry) => binding.entries.includes(entry);
|
|
6
8
|
const groupChecks = (sinks, changes) => unique(changes.map((change) => change.entry)).map((entry) => {
|
|
7
9
|
const bindings = sinks.flatMap((sink) => sink.bindings).filter((binding) => watches(binding, entry));
|
|
8
10
|
const of = changes.filter((change) => change.entry === entry);
|
|
11
|
+
const filters = [...new Map(bindings.filter((binding) => binding.conditions.length > 0).map((binding) => [binding.filter, binding.conditions])).entries()];
|
|
9
12
|
return {
|
|
10
13
|
entry,
|
|
11
14
|
bindings,
|
|
12
|
-
filters
|
|
15
|
+
filters,
|
|
16
|
+
filterIndex: new Map(filters.map(([filter], index) => [filter, index])),
|
|
13
17
|
keys: unique(of.filter((change) => change.type !== "DELETE").map((change) => change.notice.key)),
|
|
14
18
|
deleted: unique(of.filter((change) => change.type === "DELETE").map((change) => change.notice.key))
|
|
15
19
|
};
|
|
@@ -18,7 +22,7 @@ const groupChecks = (sinks, changes) => unique(changes.map((change) => change.en
|
|
|
18
22
|
* Where the open sockets of a process hear of changes: it follows the notice feed while any channel listens, and
|
|
19
23
|
* checks each batch of notices for all channels at once, one batch at a time, so Realtime holds at most one pooled
|
|
20
24
|
* connection per process, and only while a check runs. Channels whose callers have the same role and claims, such as a
|
|
21
|
-
* user's tabs, are checked as one.
|
|
25
|
+
* user's tabs, are checked as one. A channel one of whose filters Postgres refuses is removed and ended.
|
|
22
26
|
*/
|
|
23
27
|
const changeHub = ({ feed, check, entry, report }) => {
|
|
24
28
|
const sinks = /* @__PURE__ */ new Set();
|
|
@@ -59,13 +63,31 @@ const changeHub = ({ feed, check, entry, report }) => {
|
|
|
59
63
|
const view = views[groupIndex][resourceIndex];
|
|
60
64
|
const row = (type === "DELETE" ? view.deleted : view.rows).get(notice.key);
|
|
61
65
|
if (!row) return;
|
|
62
|
-
const {
|
|
66
|
+
const { filterIndex } = checks[resourceIndex];
|
|
63
67
|
for (const sink of members) {
|
|
64
68
|
if (!sinks.has(sink)) continue;
|
|
65
|
-
const ids = sink.bindings.filter((binding) => watches(binding, changed) && (binding.event === "*" || binding.event === type)).filter((binding) => binding.conditions.length === 0 || row.matches[
|
|
69
|
+
const ids = sink.bindings.filter((binding) => watches(binding, changed) && (binding.event === "*" || binding.event === type)).filter((binding) => binding.conditions.length === 0 || row.matches[filterIndex.get(binding.filter)]).map((binding) => binding.id);
|
|
66
70
|
if (ids.length > 0) sink.send(changePayload(changed, notice, type, view.columns, row, ids));
|
|
67
71
|
}
|
|
68
72
|
});
|
|
73
|
+
planned.forEach(({ members, checks }, groupIndex) => {
|
|
74
|
+
checks.forEach(({ entry: checked, filters }, resourceIndex) => {
|
|
75
|
+
for (const index of views[groupIndex][resourceIndex].failed) {
|
|
76
|
+
const [filter] = filters[index];
|
|
77
|
+
for (const sink of members) {
|
|
78
|
+
if (!sinks.has(sink) || !sink.bindings.some((binding) => watches(binding, checked) && binding.filter === filter)) continue;
|
|
79
|
+
remove(sink);
|
|
80
|
+
sink.fail(failedFilterText(filter));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
const remove = (sink) => {
|
|
87
|
+
sinks.delete(sink);
|
|
88
|
+
if (sinks.size > 0 || !subscription) return;
|
|
89
|
+
subscription.then((subscribed) => subscribed?.stop());
|
|
90
|
+
subscription = void 0;
|
|
69
91
|
};
|
|
70
92
|
const run = async () => {
|
|
71
93
|
running = true;
|
|
@@ -88,13 +110,8 @@ add: (sink) => {
|
|
|
88
110
|
report(error);
|
|
89
111
|
subscription = void 0;
|
|
90
112
|
});
|
|
91
|
-
return () =>
|
|
92
|
-
sinks.delete(sink);
|
|
93
|
-
if (sinks.size > 0 || !subscription) return;
|
|
94
|
-
subscription.then((subscribed) => subscribed?.stop());
|
|
95
|
-
subscription = void 0;
|
|
96
|
-
};
|
|
113
|
+
return () => remove(sink);
|
|
97
114
|
} };
|
|
98
115
|
};
|
|
99
116
|
//#endregion
|
|
100
|
-
export { changeHub };
|
|
117
|
+
export { changeHub, failedFilterText };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readTransaction } from "../transactions.js";
|
|
2
|
+
import { tableSql } from "../changes/capture-sql.js";
|
|
3
|
+
import { isDatabaseError } from "../error-handler.js";
|
|
4
|
+
import { callerSettings } from "../rest/caller-settings.js";
|
|
5
|
+
import { filterSql } from "./change-filter.js";
|
|
6
|
+
import { fitsOneQuery } from "./filter-chunks.js";
|
|
7
|
+
import { isStatementError } from "./savepoint-attempt.js";
|
|
8
|
+
import { boundedRunner } from "./bounded-runner.js";
|
|
9
|
+
import { sql } from "kysely";
|
|
10
|
+
//#region src/realtime/filter-check.ts
|
|
11
|
+
/** Why a join or token refresh is refused when Postgres refuses one of its filters: it names the channel's own only. */
|
|
12
|
+
const refusedFiltersText = (filters) => `Unable to subscribe to changes with given parameters: the database refused the filters [${filters.join(", ")}]`;
|
|
13
|
+
const busyText = "Too many joins are being checked; try again";
|
|
14
|
+
const regexOperators = {
|
|
15
|
+
match: "~",
|
|
16
|
+
imatch: "~*"
|
|
17
|
+
};
|
|
18
|
+
const collations = async (trx, entry, columns) => {
|
|
19
|
+
const { rows } = await sql`select a.attname as name, quote_ident(n.nspname) || '.' || quote_ident(c.collname) as collation
|
|
20
|
+
from pg_attribute a join pg_collation c on c.oid = a.attcollation join pg_namespace n on n.oid = c.collnamespace
|
|
21
|
+
where a.attrelid = to_regclass(${tableSql(entry.model)}) and a.attname = any(${[...columns]}) and not a.attisdropped`.execute(trx);
|
|
22
|
+
return new Map(rows.map((row) => [row.name, row.collation]));
|
|
23
|
+
};
|
|
24
|
+
const validate = async (trx, entry, bindings) => {
|
|
25
|
+
await sql`select ${sql.join(bindings.map((binding) => filterSql("t", binding.conditions)))} from ${sql.raw(tableSql(entry.model))} t where false`.execute(trx);
|
|
26
|
+
const patterns = bindings.flatMap((binding) => binding.conditions).filter((condition) => condition.operator === "match" || condition.operator === "imatch");
|
|
27
|
+
if (patterns.length === 0) return;
|
|
28
|
+
const collated = await collations(trx, entry, [...new Set(patterns.map((condition) => condition.column))]);
|
|
29
|
+
const probes = patterns.map(({ column, operator, value }) => {
|
|
30
|
+
const collation = collated.get(column);
|
|
31
|
+
const subject = collation === void 0 ? sql`''::text` : sql`''::text collate ${sql.raw(collation)}`;
|
|
32
|
+
return sql`${subject} ${sql.raw(regexOperators[operator])} ${value}`;
|
|
33
|
+
});
|
|
34
|
+
await sql`select ${sql.join(probes)}`.execute(trx);
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Whether Postgres takes the filters of a channel's bindings under `caller`, as Supabase checks them at join: the
|
|
38
|
+
* refusal text when it does not, so that a filter that would fail a check never joins the callers it would be checked
|
|
39
|
+
* with. One check runs at a time per process, and a few more wait, so joins never take the pooled connections that
|
|
40
|
+
* deliver changes; a join beyond those is refused.
|
|
41
|
+
*/
|
|
42
|
+
const filterCheck = ({ db, statementTimeoutMs }) => {
|
|
43
|
+
const runner = boundedRunner(1, 32);
|
|
44
|
+
return async (caller, bindings) => {
|
|
45
|
+
const filtered = bindings.filter((binding) => binding.conditions.length > 0);
|
|
46
|
+
if (filtered.length === 0) return;
|
|
47
|
+
const filters = [...new Set(filtered.map((binding) => binding.filter))];
|
|
48
|
+
if (!filtered.every((binding) => fitsOneQuery(binding.conditions))) return refusedFiltersText(filters);
|
|
49
|
+
const settings = {
|
|
50
|
+
role: "none",
|
|
51
|
+
"request.jwt.claim.sub": "",
|
|
52
|
+
...callerSettings(caller)
|
|
53
|
+
};
|
|
54
|
+
const entries = [...new Set(filtered.flatMap((binding) => binding.entries))];
|
|
55
|
+
const checked = await runner.run(() => readTransaction(db, statementTimeoutMs, async (trx) => {
|
|
56
|
+
for (const entry of entries) await validate(trx, entry, filtered.filter((binding) => binding.entries.includes(entry)));
|
|
57
|
+
return true;
|
|
58
|
+
}, settings)).catch((error) => {
|
|
59
|
+
if (isStatementError(error) || isDatabaseError(error) && error.code === "57014") return {
|
|
60
|
+
refused: false,
|
|
61
|
+
value: false
|
|
62
|
+
};
|
|
63
|
+
throw error;
|
|
64
|
+
});
|
|
65
|
+
if (checked.refused) return busyText;
|
|
66
|
+
return checked.value ? void 0 : refusedFiltersText(filters);
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
//#endregion
|
|
70
|
+
export { busyText, filterCheck, refusedFiltersText };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { filterParameters } from "./change-filter.js";
|
|
2
|
+
//#region src/realtime/filter-chunks.ts
|
|
3
|
+
/**
|
|
4
|
+
* How many filters one query evaluates: one column each, well under Postgres's 1664 target-list entries with the key
|
|
5
|
+
* columns and records beside them, and their bind parameters under the protocol's 65535 with the key list's one.
|
|
6
|
+
*/
|
|
7
|
+
const filterChunkLimits = {
|
|
8
|
+
columns: 1e3,
|
|
9
|
+
parameters: 6e4
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The filters split, in order, into runs of indexes that each fit one query. A filter that alone takes more
|
|
13
|
+
* parameters than one query may hold throws: joins refuse it first (`fitsOneQuery`).
|
|
14
|
+
*/
|
|
15
|
+
const filterChunks = (filters, limits = filterChunkLimits) => {
|
|
16
|
+
const chunks = [];
|
|
17
|
+
let current = [];
|
|
18
|
+
let parameters = 0;
|
|
19
|
+
filters.forEach((conditions, index) => {
|
|
20
|
+
const needed = filterParameters(conditions);
|
|
21
|
+
if (needed > limits.parameters) throw new Error(`A filter of ${needed} parameters does not fit one query`);
|
|
22
|
+
if (current.length === limits.columns || parameters + needed > limits.parameters) {
|
|
23
|
+
chunks.push(current);
|
|
24
|
+
current = [];
|
|
25
|
+
parameters = 0;
|
|
26
|
+
}
|
|
27
|
+
current.push(index);
|
|
28
|
+
parameters += needed;
|
|
29
|
+
});
|
|
30
|
+
return current.length > 0 ? [...chunks, current] : chunks;
|
|
31
|
+
};
|
|
32
|
+
/** Whether a filter fits one query, so that a group's check can evaluate it. */
|
|
33
|
+
const fitsOneQuery = (conditions) => filterParameters(conditions) <= filterChunkLimits.parameters;
|
|
34
|
+
//#endregion
|
|
35
|
+
export { filterChunkLimits, filterChunks, fitsOneQuery };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/realtime/filter-evaluation.ts
|
|
2
|
+
/**
|
|
3
|
+
* Evaluates the filters by index, a chunk per query (`read`), and returns the indexes of those Postgres refused. A
|
|
4
|
+
* chunk that fails is halved, and each half tried again, down to the single filters that fail alone, so one bad
|
|
5
|
+
* filter costs about two queries per halving and the others of its chunk are still evaluated. `failedFirst` is a
|
|
6
|
+
* chunk already known to fail.
|
|
7
|
+
*/
|
|
8
|
+
const evaluateFilters = async (chunks, read, failedFirst) => {
|
|
9
|
+
const failed = /* @__PURE__ */ new Set();
|
|
10
|
+
const narrow = async (indexes) => {
|
|
11
|
+
if (indexes.length === 1) {
|
|
12
|
+
failed.add(indexes[0]);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const middle = Math.ceil(indexes.length / 2);
|
|
16
|
+
await tryChunk(indexes.slice(0, middle));
|
|
17
|
+
await tryChunk(indexes.slice(middle));
|
|
18
|
+
};
|
|
19
|
+
const tryChunk = async (indexes) => {
|
|
20
|
+
if (!(await read(indexes)).ok) await narrow(indexes);
|
|
21
|
+
};
|
|
22
|
+
if (failedFirst && failedFirst.length > 0) await narrow(failedFirst);
|
|
23
|
+
for (const chunk of chunks) await tryChunk(chunk);
|
|
24
|
+
return failed;
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { evaluateFilters };
|
|
@@ -2,6 +2,7 @@ import { broadcastHub } from "./broadcast-hub.js";
|
|
|
2
2
|
import { changeChecker } from "./change-checker.js";
|
|
3
3
|
import { changeHub } from "./change-hub.js";
|
|
4
4
|
import { channelAccess } from "./channel-access.js";
|
|
5
|
+
import { filterCheck } from "./filter-check.js";
|
|
5
6
|
import { databaseBroadcasts } from "./database-broadcasts.js";
|
|
6
7
|
import { presenceHub } from "./presence-hub.js";
|
|
7
8
|
import { realtimeRoutes } from "./realtime-routes.js";
|
|
@@ -40,6 +41,10 @@ const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report, options })
|
|
|
40
41
|
entry: (resource) => live.find((entry) => entry.name === resource),
|
|
41
42
|
report
|
|
42
43
|
});
|
|
44
|
+
const checkFilters = filterCheck({
|
|
45
|
+
db: deps.db,
|
|
46
|
+
statementTimeoutMs: deps.statementTimeoutMs
|
|
47
|
+
});
|
|
43
48
|
const access = channelAccess({
|
|
44
49
|
db: deps.db,
|
|
45
50
|
statementTimeoutMs: deps.statementTimeoutMs
|
|
@@ -56,6 +61,7 @@ const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report, options })
|
|
|
56
61
|
caller,
|
|
57
62
|
live,
|
|
58
63
|
hub,
|
|
64
|
+
checkFilters,
|
|
59
65
|
broadcasts,
|
|
60
66
|
presences,
|
|
61
67
|
database: { follow: () => {
|
|
@@ -17,7 +17,7 @@ const keyRequest = (apikey, token) => new Request("http://realtime.invalid/", {
|
|
|
17
17
|
* `POST /api/sb/realtime/v1/api/broadcast` sends to the same channels without a socket (see `broadcastApi`), and so
|
|
18
18
|
* does the database (see `databaseBroadcasts`).
|
|
19
19
|
*/
|
|
20
|
-
const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, broadcasts, presences, database, access, report }) => {
|
|
20
|
+
const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, checkFilters, broadcasts, presences, database, access, report }) => {
|
|
21
21
|
const app = new Hono();
|
|
22
22
|
let lastId = 0;
|
|
23
23
|
const nextId = () => ++lastId;
|
|
@@ -54,6 +54,7 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, broadcasts, prese
|
|
|
54
54
|
resolve: (token) => caller(keyRequest(apikey, token)),
|
|
55
55
|
live,
|
|
56
56
|
hub,
|
|
57
|
+
checkFilters,
|
|
57
58
|
broadcasts,
|
|
58
59
|
presences,
|
|
59
60
|
access,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { HttpProblem } from "../problem.js";
|
|
2
2
|
import { fixedWindow, fixedWindows } from "./fixed-window.js";
|
|
3
3
|
import { realtimeLimits } from "./realtime-limits.js";
|
|
4
|
+
import { callerSettings } from "../rest/caller-settings.js";
|
|
4
5
|
import { parseBindings } from "./postgres-changes.js";
|
|
6
|
+
import "./filter-check.js";
|
|
5
7
|
import { decodeBroadcastPush, encodeBroadcast, fitsFrame } from "./broadcast-frame.js";
|
|
6
8
|
import { decodeMessage, encodeMessage, reply } from "./phoenix-message.js";
|
|
7
9
|
import { privateGate, publicGate, readRefusalText } from "./channel-gate.js";
|
|
@@ -37,7 +39,7 @@ const refusal = (error) => {
|
|
|
37
39
|
* earlier messages wait. A close, the client's or the server's, leaves every channel, a join still checking its token
|
|
38
40
|
* included, and drops whatever is still queued.
|
|
39
41
|
*/
|
|
40
|
-
const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadcasts, presences, access, nextId, report, idleMs = 6e4, now = Date.now }) => {
|
|
42
|
+
const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, checkFilters, broadcasts, presences, access, nextId, report, idleMs = 6e4, now = Date.now }) => {
|
|
41
43
|
const channels = /* @__PURE__ */ new Map();
|
|
42
44
|
let queue = Promise.resolve();
|
|
43
45
|
let queuedMessages = 0;
|
|
@@ -127,6 +129,9 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
|
|
|
127
129
|
if (!open) return;
|
|
128
130
|
const parsed = parseBindings(config.postgres_changes, live, nextId);
|
|
129
131
|
if (typeof parsed === "string") return push(reply(message, "error", { reason: parsed }));
|
|
132
|
+
const refusedFilters = await checkFilters(caller, parsed.bindings);
|
|
133
|
+
if (!open) return;
|
|
134
|
+
if (refusedFilters !== void 0) return push(reply(message, "error", { reason: refusedFilters }));
|
|
130
135
|
const { topic } = message;
|
|
131
136
|
const member = {
|
|
132
137
|
self: broadcast.self === true,
|
|
@@ -146,11 +151,13 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
|
|
|
146
151
|
const offChanges = parsed.bindings.length > 0 ? hub.add({
|
|
147
152
|
caller: () => channel.caller,
|
|
148
153
|
bindings: parsed.bindings,
|
|
149
|
-
send: (payload) => toChannel(topic, channel, "postgres_changes", payload)
|
|
154
|
+
send: (payload) => toChannel(topic, channel, "postgres_changes", payload),
|
|
155
|
+
fail: (reason) => channels.get(topic) === channel && end(topic, reason)
|
|
150
156
|
}) : () => {};
|
|
151
157
|
const channel = {
|
|
152
158
|
joinRef: message.joinRef,
|
|
153
159
|
caller,
|
|
160
|
+
bindings: parsed.bindings,
|
|
154
161
|
gate,
|
|
155
162
|
ack: broadcast.ack === true,
|
|
156
163
|
member,
|
|
@@ -180,8 +187,13 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
|
|
|
180
187
|
const refused = await channel.gate.authorize(caller);
|
|
181
188
|
if (channels.get(message.topic) !== channel) return;
|
|
182
189
|
if (refused) return end(message.topic, readRefusalText(message.topic, refused));
|
|
190
|
+
const changed = JSON.stringify(callerSettings(caller)) !== JSON.stringify(callerSettings(channel.caller));
|
|
183
191
|
channel.caller = caller;
|
|
184
192
|
expireAt(message.topic, channel);
|
|
193
|
+
if (!changed) return;
|
|
194
|
+
const refusedFilters = await checkFilters(caller, channel.bindings);
|
|
195
|
+
if (channels.get(message.topic) !== channel || refusedFilters === void 0 || refusedFilters === "Too many joins are being checked; try again") return;
|
|
196
|
+
end(message.topic, refusedFilters);
|
|
185
197
|
};
|
|
186
198
|
const sendBroadcast = async (message, channel) => {
|
|
187
199
|
const { event, payload } = message.payload;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { isDatabaseError } from "../error-handler.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/realtime/savepoint-attempt.ts
|
|
4
|
+
const transient = /* @__PURE__ */ new Set([
|
|
5
|
+
"08",
|
|
6
|
+
"25",
|
|
7
|
+
"40",
|
|
8
|
+
"53",
|
|
9
|
+
"57",
|
|
10
|
+
"58",
|
|
11
|
+
"XX"
|
|
12
|
+
]);
|
|
13
|
+
/** Whether Postgres refused the statement itself, such as a value its column's type does not take. */
|
|
14
|
+
const isStatementError = (error) => isDatabaseError(error) && !transient.has(error.code.slice(0, 2));
|
|
15
|
+
/**
|
|
16
|
+
* Runs `run` inside the savepoint `name` of `trx`: when Postgres refuses one of its statements, the transaction goes
|
|
17
|
+
* back to the savepoint, keeping the settings made before it, and the error is returned. Any other error is thrown.
|
|
18
|
+
*/
|
|
19
|
+
const attempt = async (trx, name, run) => {
|
|
20
|
+
await sql`savepoint ${sql.id(name)}`.execute(trx);
|
|
21
|
+
try {
|
|
22
|
+
const value = await run();
|
|
23
|
+
await sql`release savepoint ${sql.id(name)}`.execute(trx);
|
|
24
|
+
return {
|
|
25
|
+
ok: true,
|
|
26
|
+
value
|
|
27
|
+
};
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (!isStatementError(error)) throw error;
|
|
30
|
+
await sql`rollback to savepoint ${sql.id(name)}`.execute(trx);
|
|
31
|
+
await sql`release savepoint ${sql.id(name)}`.execute(trx);
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { attempt, isStatementError };
|
package/dist/transactions.js
CHANGED
|
@@ -17,5 +17,10 @@ const readTransaction = (db, timeoutMs, run, settings = {}) => writeTransaction(
|
|
|
17
17
|
await sql`set transaction read only`.execute(trx);
|
|
18
18
|
return run(trx);
|
|
19
19
|
}, settings);
|
|
20
|
+
/** A read-only transaction whose statements all read one snapshot (REPEATABLE READ), cancelled after `timeoutMs` each. */
|
|
21
|
+
const snapshotReadTransaction = (db, timeoutMs, run) => db.transaction().setIsolationLevel("repeatable read").setAccessMode("read only").execute(async (trx) => {
|
|
22
|
+
await sql`select set_config('statement_timeout', ${String(timeoutMs)}, true)`.execute(trx);
|
|
23
|
+
return run(trx);
|
|
24
|
+
});
|
|
20
25
|
//#endregion
|
|
21
|
-
export { readTransaction, writeTransaction };
|
|
26
|
+
export { readTransaction, snapshotReadTransaction, writeTransaction };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.10",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"@better-auth/passkey": "^1.7.12",
|
|
41
41
|
"@better-auth/sso": "^1.7.12",
|
|
42
42
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
43
|
-
"@protobase/layout": "0.55.
|
|
44
|
-
"@protobase/query": "0.55.
|
|
45
|
-
"@protobase/schema": "0.55.
|
|
43
|
+
"@protobase/layout": "0.55.10",
|
|
44
|
+
"@protobase/query": "0.55.10",
|
|
45
|
+
"@protobase/schema": "0.55.10",
|
|
46
46
|
"@react-email/render": "^2.1.0",
|
|
47
47
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
48
48
|
"@sentry/core": "^11.7.0",
|