@protobase/server 0.55.8 → 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 CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.55.8";
2
+ var version = "0.55.10";
3
3
  //#endregion
4
4
  export { version };
@@ -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 { readTransaction } from "../transactions.js";
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) => filters.length === 0 ? sql`` : sql`, ${sql.join(filters.map((conditions, index) => sql`${filterSql(alias, conditions)} as ${sql.ref(`f${index}`)}`))}`;
20
- const seen = (entry, filters, rows) => new Map(rows.map((row) => [encodeKey(keyColumns(entry).map((_, index) => row[`k${index}`])), {
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((_, index) => row[`f${index}`] === true)
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: per group it sets the role and
27
- * claims as `/api/sb/rest/v1` does, then reads the current rows by key, so the table's RLS policies decide, and evaluates
28
- * each filter in Postgres, as the column's type compares. The transaction holds one pooled connection only while it
29
- * runs. A group whose check fails is checked again in a transaction of its own, so it costs only its own callers the
30
- * batch.
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 view = async (trx, role, check) => {
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
- const rows = check.keys.length === 0 ? [] : (await sql`select ${keyTexts("t", check.entry)}, (select to_jsonb(v) from (select ${listed("t", names)}) v) as record,
54
- (select to_jsonb(v) from (select ${key}) v) as old_record${filterColumns("t", check.filters)}
55
- 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;
56
- const 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)}
57
- from jsonb_populate_recordset(null::${table}, ${keyRows(check.entry, check.deleted)}::jsonb) k`.execute(trx)).rows;
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: seen(check.entry, check.filters, rows),
61
- deleted: seen(check.entry, check.filters, deleted)
110
+ rows,
111
+ deleted,
112
+ failed
62
113
  };
63
114
  };
64
- const checkGroups = (groups) => readTransaction(db, statementTimeoutMs, async (trx) => {
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 nothing = (group) => group.resources.map(() => ({
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 [nothing(groups[0])];
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 nothing(group);
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: unquote(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: [...new Map(bindings.filter((binding) => binding.conditions.length > 0).map((binding) => [binding.filter, binding.conditions])).entries()],
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 { filters } = checks[resourceIndex];
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[filters.findIndex(([filter]) => filter === binding.filter)]).map((binding) => binding.id);
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 };
@@ -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.8",
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.8",
44
- "@protobase/query": "0.55.8",
45
- "@protobase/schema": "0.55.8",
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",