@geonosis/search 0.1.1 → 0.3.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,79 @@
1
+ # @geonosis/search
2
+
3
+ ## 0.3.3
4
+
5
+ ### Patch Changes
6
+
7
+ - d15a654: **A bump names what the floors it crossed changed, and runs the tier it left behind (#268).** The
8
+ `BY HAND` block was driven by the doctor's required-option FAILs alone, so `--floors latest` across
9
+ a floor minor that changed `sessionDb`'s key printed nothing and the consumer's own tests found it.
10
+ `update` now reads each bumped floor's own `CHANGELOG.md` out of what the install unpacked, for the
11
+ versions crossed, and prints every change its author named — and says so by name when a floor ships
12
+ no changelog to read (measured: `npm pack --dry-run --json` over a floor carries `dist`,
13
+ `package.json`, `README.md` and `LICENSE`; CHANGELOG is not on npm's always-included list, so the
14
+ floors name it in `files` now). A seventh ritual step runs the tier this tree calls `fast` in
15
+ `geonosis.json` after the frozen install: a bump cannot report PASS over a tree it has just
16
+ reddened, and a repo that declares no tier is told so rather than passed over.
17
+ - Updated dependencies [d15a654]
18
+ - Updated dependencies [d15a654]
19
+ - @geonosis/db@0.5.1
20
+ - @geonosis/conformance@0.2.1
21
+
22
+ ## 0.3.2
23
+
24
+ ### Patch Changes
25
+
26
+ - Updated dependencies [5bc2eed]
27
+ - Updated dependencies [ccf04d8]
28
+ - @geonosis/db@0.5.0
29
+
30
+ ## 0.3.1
31
+
32
+ ### Patch Changes
33
+
34
+ - Updated dependencies [9d76504]
35
+ - @geonosis/db@0.4.0
36
+
37
+ ## 0.3.0
38
+
39
+ ### Minor Changes
40
+
41
+ - 1e57a7f: **The table name is an option, and the migrations say which squawk findings they accept (#244).**
42
+ `search_index` was compiled into the DDL and the provider, so a consumer whose schema already held
43
+ one had to drop theirs. `SearchIndexSettings.table` and `PgFtsOptions.table` name it — default
44
+ `search_index`, and every identifier the DDL builds is derived from it (the primary key, the GIN
45
+ index, the `recent` index), so two of these live in one schema. The shipped `.sql` files carry
46
+ `{{table}}` beside the session tokens, and a name that could close the quotes it is written inside
47
+ is refused. The whole conformance exam runs a second time, on real Postgres 17 and 18, against a
48
+ table this package does not name. Every statement squawk 2.63.0 reports on now ships with the
49
+ finding accepted in squawk's own syntax and the reason on the line above it — measured: 8 findings
50
+ across four rules before, 0 after, rendered or tokenised. `ban-drop-table` was never one of them.
51
+
52
+ ### Patch Changes
53
+
54
+ - 5c32252: **A query with no term in it is refused, not answered with every row (#243).** `?!?` — or a box
55
+ somebody hit the space bar in — carries no letter or digit, so `pgFtsSearchIndex` built a read with
56
+ no match predicate at all and answered with the whole index, ordered and paged: a full scan wearing
57
+ the shape of a search, and the more rows a tenant has the worse it gets. `readSearchQuery` now
58
+ refuses it by `SearchRefusal`, so every provider that reads its query through the contract is fixed
59
+ at once, and the provider's read can no longer lose its predicate. `carriesATerm(terms)` asks the
60
+ same question before the call, for a caller who would rather disable a button than catch. The case
61
+ is in `vocabularyConformance`, so an implementation that answers it is caught by the exam.
62
+ - Updated dependencies [91e1b2d]
63
+ - Updated dependencies [cb21027]
64
+ - @geonosis/db@0.3.0
65
+
66
+ ## 0.2.0
67
+
68
+ ### Minor Changes
69
+
70
+ - 13ca75b: **Every conformance case that can name its refusal now names it (#213).** `assertRefuses` takes the class or a phrase of the refusal, and five cases were still passing nothing: an index that refused an undeclared filter, value or ordering with any error at all passed the search exam, and a journal that refused a held idempotency key with a raw uniqueness violation passed two of the three cases about it. The search cases require `SearchRefusal` and the journal cases `IdempotencyKeyHeldError` — the classes both contracts already export for exactly this. A subject that refuses with something else now fails, naming what it threw.
71
+
72
+ ## 0.1.1
73
+
74
+ ### Patch Changes
75
+
76
+ - Updated dependencies [bb5e84b]
77
+ - Updated dependencies [54fba77]
78
+ - @geonosis/conformance@0.2.0
79
+ - @geonosis/db@0.2.0
package/README.md CHANGED
@@ -40,6 +40,11 @@ naming what it could not answer. A filter nobody declared, a value that filter n
40
40
  undeclared ordering are all refusals — because the alternative is an empty answer read as a count
41
41
  of zero, and the refusal says so.
42
42
 
43
+ Terms with no letter or digit in them — `?!?`, a box someone hit space in — are refused too, and
44
+ that one is the opposite failure: every row matches nothing equally, so the page that comes back is
45
+ the whole index. `carriesATerm(terms)` is the same question asked before the refusal, for a caller
46
+ who wants to disable a button rather than catch an exception.
47
+
43
48
  `DEFAULT_LIMIT` is 50 when a query names no limit.
44
49
 
45
50
  ### Pages are keyset, never offset
@@ -65,7 +70,9 @@ const index = pgFtsSearchIndex({
65
70
  })
66
71
  ```
67
72
 
68
- `PgFtsOptions` is `{ executor, nowMs, projections }`. The vocabulary is DERIVED from the
73
+ `PgFtsOptions` is `{ executor, nowMs, projections, table? }`. `table` is the one the migrations were
74
+ rendered with — `search_index` when nobody names one — because a consumer whose schema already holds
75
+ a `search_index` should not have to drop it. The vocabulary is DERIVED from the
69
76
  projections: the kinds it can index are the kinds it will filter by, so there is no second list to
70
77
  keep in step. It answers two orderings, `relevance` (rank) and `recent` (the instant the row was
71
78
  indexed).
@@ -82,11 +89,41 @@ for the `recent` ordering, and row level security ENABLED AND FORCED with a tena
82
89
  ops-maintenance policy.
83
90
 
84
91
  The session variable names are the consumer's vocabulary and have no default (#168). The shipped
85
- files carry the `MIGRATION_PLACEHOLDERS` tokens — `{{tenantSetting}}` and `{{opsSetting}}` — for a
86
- migrator that templates. For one that does not, `searchIndexMigrations(settings)` returns the same
87
- DDL rendered, as `SearchMigration[]` (`{ name, statements }`), refusing a `SearchIndexSettings`
88
- whose names are not the `prefix.name` Postgres accepts. `DEFAULT_OPS_VALUE` is what the ops setting
89
- holds while the lever is pulled; `SEARCH_INDEX_TABLE` is the one table this domain owns.
92
+ files carry the `MIGRATION_PLACEHOLDERS` tokens — `{{tenantSetting}}`, `{{opsSetting}}` and
93
+ `{{table}}` — for a migrator that templates. For one that does not,
94
+ `searchIndexMigrations(settings)` returns the same DDL rendered, as `SearchMigration[]`
95
+ (`{ name, statements }`), refusing a `SearchIndexSettings` whose names are not the `prefix.name`
96
+ Postgres accepts. `DEFAULT_OPS_VALUE` is what the ops setting holds while the lever is pulled;
97
+ `SEARCH_INDEX_TABLE` is the default table name.
98
+
99
+ `settings.table` is the table itself, and every identifier the DDL builds is derived from it — the
100
+ primary key, the GIN index and the `recent` index — so two of these can live in one schema. Pass
101
+ the same name to `pgFtsSearchIndex`. A name outside `letter_or_underscore` followed by letters,
102
+ digits, underscores or `$` is refused: it is inlined into DDL inside quotes it must not be able to
103
+ close.
104
+
105
+ ### What squawk says about this DDL, and why it stands
106
+
107
+ Every statement squawk 2.63.0 has a finding on ships with the finding accepted in squawk's own
108
+ syntax — the reason on its own comment line, then `-- squawk-ignore <rules>`, then the statement.
109
+ (The reason may not share the ignore line: squawk reads everything after the rule list as another
110
+ rule name, and then ignores nothing.) `squawk migrations/*.sql` on the shipped files is 0 issues,
111
+ and stays 0 once they are rendered.
112
+
113
+ - **`require-concurrent-index-creation`** on both `create index` statements. `CONCURRENTLY` cannot
114
+ run inside a transaction, and a migrator that applies a file in one — this kit's own does, and a
115
+ consumer's usually does — would fail on it. The index is created in the same migration as its
116
+ table, so it is built over no rows.
117
+ - **`require-lock-timeout`** and **`require-statement-timeout`**, on the first index and on
118
+ `enable row level security`. Both values are one deployment's numbers; DDL that shipped a
119
+ `set lock_timeout` would impose that deployment's budget on every consumer. Set them in the
120
+ session that migrates.
121
+ - **`prefer-robust-stmts`** on both `alter table` statements. The file is applied inside a
122
+ transaction and every statement in it is idempotent (`if not exists`, `drop policy if exists`),
123
+ so a run that fails part way leaves nothing behind and a rerun is a no-op.
124
+
125
+ Nothing here drops anything: the only `drop` is `drop policy if exists`, which is how a policy is
126
+ replaced, and `ban-drop-table` never fires.
90
127
 
91
128
  ## One door, and what holds it
92
129
 
@@ -116,9 +153,10 @@ the two apart from outside.
116
153
 
117
154
  The three sets, which can be run alone:
118
155
 
119
- - `vocabularyConformance` — a filter, a value or an ordering the index never declared is REFUSED,
120
- every declared filter publishes its values, and the control a query inside the vocabulary is
121
- answered, because an index that refused everything would pass the refusals;
156
+ - `vocabularyConformance` — a filter, a value or an ordering the index never declared is REFUSED, a
157
+ query carrying no term at all is refused rather than answered with every row, every declared
158
+ filter publishes its values, and the control a query inside the vocabulary is answered,
159
+ because an index that refused everything would pass the refusals;
122
160
  - `derivedProjectionConformance` — findable by what was derived, invisible to what was not, one row
123
161
  per document however often it is indexed, and gone once retired;
124
162
  - `tenantIsolationConformance` — a tenant finds only its own, a read that names no tenant finds
package/dist/index.cjs CHANGED
@@ -25,6 +25,7 @@ __export(index_exports, {
25
25
  MIGRATION_PLACEHOLDERS: () => MIGRATION_PLACEHOLDERS,
26
26
  SEARCH_INDEX_TABLE: () => SEARCH_INDEX_TABLE,
27
27
  SearchRefusal: () => SearchRefusal,
28
+ carriesATerm: () => carriesATerm,
28
29
  derivedProjectionConformance: () => derivedProjectionConformance,
29
30
  encodeCursor: () => encodeCursor,
30
31
  pgFtsSearchIndex: () => pgFtsSearchIndex,
@@ -39,8 +40,99 @@ module.exports = __toCommonJS(index_exports);
39
40
 
40
41
  // src/conformance.ts
41
42
  var import_conformance = require("@geonosis/conformance");
43
+
44
+ // src/contract.ts
45
+ var SearchRefusal = class extends Error {
46
+ constructor(message) {
47
+ super(message);
48
+ this.name = "SearchRefusal";
49
+ }
50
+ };
51
+ var NOT_ZERO = "an empty answer to this is not a count of zero \u2014 it is a question nothing could match, and it is refused rather than answered";
52
+ var DEFAULT_LIMIT = 50;
53
+ var TERM = /[\p{L}\p{N}]/u;
54
+ var carriesATerm = (terms) => TERM.test(terms);
55
+ var readSearchQuery = (vocabulary, terms, options = {}) => {
56
+ if (!carriesATerm(terms)) {
57
+ throw new SearchRefusal(
58
+ `"${terms}" holds no letter or digit, so there is nothing in it to match: every row this index has matches it equally, and the page that comes back is a full scan wearing the shape of a search. A caller who wants the rows themselves is asking for a listing, which is a different question`
59
+ );
60
+ }
61
+ const orderings = vocabulary.orderings;
62
+ const first = orderings[0];
63
+ if (first === void 0) {
64
+ throw new SearchRefusal(
65
+ "this index declares no ordering, and a page of results is a sequence: declare at least one, because unordered rows cannot be paged by their key at all"
66
+ );
67
+ }
68
+ const order = options.order ?? first;
69
+ if (!orderings.includes(order)) {
70
+ throw new SearchRefusal(
71
+ `"${order}" is not an ordering this index declares \u2014 it orders by ${[...orderings].toSorted().join(", ")}. ${NOT_ZERO}`
72
+ );
73
+ }
74
+ const filters = Object.entries(options.filters ?? {}).map(([name, value]) => {
75
+ const values = vocabulary.filters[name];
76
+ if (values === void 0) {
77
+ throw new SearchRefusal(
78
+ `"${name}" is not a filter this index declares \u2014 it filters by ${Object.keys(vocabulary.filters).toSorted().join(", ")}. ${NOT_ZERO}`
79
+ );
80
+ }
81
+ if (!values.includes(value)) {
82
+ throw new SearchRefusal(
83
+ `"${name}" takes ${[...values].toSorted().join(", ")}, and was asked for "${value}". ${NOT_ZERO}`
84
+ );
85
+ }
86
+ return { name, value };
87
+ });
88
+ return {
89
+ ...options.after === void 0 ? {} : { after: readCursor(options.after, { order, terms }) },
90
+ filters,
91
+ limit: options.limit ?? DEFAULT_LIMIT,
92
+ order,
93
+ terms
94
+ };
95
+ };
96
+ var encodeCursor = (key) => encodeURIComponent(JSON.stringify(key));
97
+ var KEYS = ["at", "id", "kind", "order", "rank", "terms"];
98
+ var parsed = (cursor) => {
99
+ let value;
100
+ try {
101
+ value = JSON.parse(decodeURIComponent(cursor));
102
+ } catch {
103
+ throw new SearchRefusal(
104
+ "this cursor was not minted by this index: a page is reached by the key of the row before it, and that key is not readable here"
105
+ );
106
+ }
107
+ if (typeof value !== "object" || value === null) {
108
+ throw new SearchRefusal("this cursor holds no key at all");
109
+ }
110
+ const held = value;
111
+ const missing = KEYS.filter((key) => held[key] === void 0);
112
+ if (missing.length > 0) {
113
+ throw new SearchRefusal(`this cursor is missing ${missing.join(", ")}`);
114
+ }
115
+ return held;
116
+ };
117
+ var readCursor = (cursor, asked) => {
118
+ const key = parsed(cursor);
119
+ if (key.terms !== asked.terms) {
120
+ throw new SearchRefusal(
121
+ `this cursor was minted for "${key.terms}" and the query asks "${asked.terms}": ranks are comparable only within one question, so the page after it would be another query's`
122
+ );
123
+ }
124
+ if (key.order !== asked.order) {
125
+ throw new SearchRefusal(
126
+ `this cursor was minted under the "${key.order}" ordering and the query asks for "${asked.order}"`
127
+ );
128
+ }
129
+ return key;
130
+ };
131
+
132
+ // src/conformance.ts
42
133
  var A = "tenant_conformance_a";
43
134
  var B = "tenant_conformance_b";
135
+ var TERMLESS = "?!?";
44
136
  var twoExamples = (subject) => {
45
137
  const [first, second] = subject.examples;
46
138
  if (first === void 0 || second === void 0) {
@@ -84,7 +176,8 @@ var vocabularyConformance = (subject) => {
84
176
  A,
85
177
  (index) => (0, import_conformance.assertRefuses)(
86
178
  () => index.query(first.findableBy, { filters: { conformance_unknown: "x" } }),
87
- "a filter no vocabulary declares was answered rather than refused, and the empty answer it gives reads as a count of zero"
179
+ "a filter no vocabulary declares was answered rather than refused, and the empty answer it gives reads as a count of zero",
180
+ SearchRefusal
88
181
  )
89
182
  );
90
183
  }
@@ -98,7 +191,22 @@ var vocabularyConformance = (subject) => {
98
191
  () => index.query(first.findableBy, {
99
192
  filters: { [filter.name]: "conformance_undeclared_value" }
100
193
  }),
101
- `"${filter.name}" takes ${[...filter.values].toSorted().join(", ")} and answered a value outside that list: publishing filter names without their values is how a caller filters for something nothing emits and reports the empty result as a count`
194
+ `"${filter.name}" takes ${[...filter.values].toSorted().join(", ")} and answered a value outside that list: publishing filter names without their values is how a caller filters for something nothing emits and reports the empty result as a count`,
195
+ SearchRefusal
196
+ );
197
+ });
198
+ }
199
+ },
200
+ {
201
+ name: "a query with no term in it is refused rather than answered with every row",
202
+ run: async () => {
203
+ await subject.reset();
204
+ await subject.inTenant(A, async (index) => {
205
+ await index.index(first.document);
206
+ await (0, import_conformance.assertRefuses)(
207
+ () => index.query(TERMLESS),
208
+ `"${TERMLESS}" carries nothing to match on, and the answer to it was a page rather than a refusal: a query whose terms hold no letter or digit selects every row this index has, which is a full scan wearing the shape of a search`,
209
+ SearchRefusal
102
210
  );
103
211
  });
104
212
  }
@@ -110,7 +218,8 @@ var vocabularyConformance = (subject) => {
110
218
  A,
111
219
  (index) => (0, import_conformance.assertRefuses)(
112
220
  () => index.query(first.findableBy, { order: "conformance_unknown" }),
113
- "an ordering nobody declared was accepted, so the rows came back in whatever order the storage felt like and the page after them is unreachable"
221
+ "an ordering nobody declared was accepted, so the rows came back in whatever order the storage felt like and the page after them is unreachable",
222
+ SearchRefusal
114
223
  )
115
224
  );
116
225
  }
@@ -252,95 +361,25 @@ var searchConformance = (subject) => [
252
361
  ...tenantIsolationConformance(subject)
253
362
  ];
254
363
 
255
- // src/contract.ts
256
- var SearchRefusal = class extends Error {
257
- constructor(message) {
258
- super(message);
259
- this.name = "SearchRefusal";
260
- }
261
- };
262
- var NOT_ZERO = "an empty answer to this is not a count of zero \u2014 it is a question nothing could match, and it is refused rather than answered";
263
- var DEFAULT_LIMIT = 50;
264
- var readSearchQuery = (vocabulary, terms, options = {}) => {
265
- const orderings = vocabulary.orderings;
266
- const first = orderings[0];
267
- if (first === void 0) {
268
- throw new SearchRefusal(
269
- "this index declares no ordering, and a page of results is a sequence: declare at least one, because unordered rows cannot be paged by their key at all"
270
- );
271
- }
272
- const order = options.order ?? first;
273
- if (!orderings.includes(order)) {
274
- throw new SearchRefusal(
275
- `"${order}" is not an ordering this index declares \u2014 it orders by ${[...orderings].toSorted().join(", ")}. ${NOT_ZERO}`
276
- );
277
- }
278
- const filters = Object.entries(options.filters ?? {}).map(([name, value]) => {
279
- const values = vocabulary.filters[name];
280
- if (values === void 0) {
281
- throw new SearchRefusal(
282
- `"${name}" is not a filter this index declares \u2014 it filters by ${Object.keys(vocabulary.filters).toSorted().join(", ")}. ${NOT_ZERO}`
283
- );
284
- }
285
- if (!values.includes(value)) {
286
- throw new SearchRefusal(
287
- `"${name}" takes ${[...values].toSorted().join(", ")}, and was asked for "${value}". ${NOT_ZERO}`
288
- );
289
- }
290
- return { name, value };
291
- });
292
- return {
293
- ...options.after === void 0 ? {} : { after: readCursor(options.after, { order, terms }) },
294
- filters,
295
- limit: options.limit ?? DEFAULT_LIMIT,
296
- order,
297
- terms
298
- };
299
- };
300
- var encodeCursor = (key) => encodeURIComponent(JSON.stringify(key));
301
- var KEYS = ["at", "id", "kind", "order", "rank", "terms"];
302
- var parsed = (cursor) => {
303
- let value;
304
- try {
305
- value = JSON.parse(decodeURIComponent(cursor));
306
- } catch {
307
- throw new SearchRefusal(
308
- "this cursor was not minted by this index: a page is reached by the key of the row before it, and that key is not readable here"
309
- );
310
- }
311
- if (typeof value !== "object" || value === null) {
312
- throw new SearchRefusal("this cursor holds no key at all");
313
- }
314
- const held = value;
315
- const missing = KEYS.filter((key) => held[key] === void 0);
316
- if (missing.length > 0) {
317
- throw new SearchRefusal(`this cursor is missing ${missing.join(", ")}`);
318
- }
319
- return held;
320
- };
321
- var readCursor = (cursor, asked) => {
322
- const key = parsed(cursor);
323
- if (key.terms !== asked.terms) {
324
- throw new SearchRefusal(
325
- `this cursor was minted for "${key.terms}" and the query asks "${asked.terms}": ranks are comparable only within one question, so the page after it would be another query's`
326
- );
327
- }
328
- if (key.order !== asked.order) {
329
- throw new SearchRefusal(
330
- `this cursor was minted under the "${key.order}" ordering and the query asks for "${asked.order}"`
331
- );
332
- }
333
- return key;
334
- };
335
-
336
364
  // src/provider/ddl.ts
337
365
  var SEARCH_INDEX_TABLE = "search_index";
338
366
  var MIGRATION_PLACEHOLDERS = {
339
367
  opsSetting: "{{opsSetting}}",
368
+ table: "{{table}}",
340
369
  tenantSetting: "{{tenantSetting}}"
341
370
  };
342
371
  var DEFAULT_OPS_VALUE = "on";
343
372
  var CUSTOM_SETTING = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
373
+ var TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_$]*$/;
374
+ var readTableName = (name) => {
375
+ const table = name ?? SEARCH_INDEX_TABLE;
376
+ if (!TABLE_NAME.test(table)) {
377
+ throw new SearchRefusal(
378
+ `"${table}" is not a table name this DDL can write: it is inlined into every statement inside double quotes, and a name outside \`letter_or_underscore followed by letters, digits, underscores or $\` could close them. Name the schema in the search_path rather than here`
379
+ );
380
+ }
381
+ return table;
382
+ };
344
383
  var readSettings = (settings) => {
345
384
  for (const named of ["opsSetting", "tenantSetting"]) {
346
385
  const name = settings[named];
@@ -355,14 +394,30 @@ var readSettings = (settings) => {
355
394
  );
356
395
  }
357
396
  }
358
- return { ...settings, opsValue: settings.opsValue ?? DEFAULT_OPS_VALUE };
397
+ return {
398
+ ...settings,
399
+ opsValue: settings.opsValue ?? DEFAULT_OPS_VALUE,
400
+ table: readTableName(settings.table)
401
+ };
359
402
  };
360
403
  var quoted = (identifier) => `"${identifier}"`;
361
404
  var setting = (name) => `current_setting('${name}', true)`;
362
405
  var both = (predicate) => `using (${predicate}) with check (${predicate})`;
363
- var table = quoted(SEARCH_INDEX_TABLE);
364
- var searchIndexTable = (tenantSetting) => [
365
- `create table if not exists ${table} (
406
+ var accepted = (because, rules, statement) => `-- ${because}
407
+ -- squawk-ignore ${rules.join(", ")}
408
+ ${statement}`;
409
+ var TIMEOUTS_BELONG_TO_THE_DEPLOYMENT = "lock_timeout and statement_timeout are one deployment\u2019s numbers, and a value written into shipped DDL imposes that deployment\u2019s budget on every consumer: set them in the session that migrates.";
410
+ var IN_ONE_TRANSACTION = "The migrator applies this file inside a transaction, which is also why CONCURRENTLY is not available here; the index is created in the same migration as its table, over no rows.";
411
+ var RERUNNABLE = "This file is applied inside a transaction and every statement in it is idempotent, so a run that fails part way through leaves nothing behind and a rerun is a no-op.";
412
+ var searchIndexTable = (tenantSetting, name) => {
413
+ const table = quoted(name);
414
+ const index = (suffix, over) => accepted(
415
+ `${IN_ONE_TRANSACTION} ${TIMEOUTS_BELONG_TO_THE_DEPLOYMENT}`,
416
+ ["require-concurrent-index-creation", "require-lock-timeout", "require-statement-timeout"],
417
+ `create index if not exists ${quoted(`${name}_${suffix}`)} on ${table} ${over}`
418
+ );
419
+ return [
420
+ `create table if not exists ${table} (
366
421
  "tenant_id" text not null default ${setting(tenantSetting)},
367
422
  "kind" text not null,
368
423
  "id" text not null,
@@ -370,53 +425,69 @@ var searchIndexTable = (tenantSetting) => [
370
425
  "body" text not null,
371
426
  "indexed_at" bigint not null,
372
427
  "search" tsvector generated always as (to_tsvector('simple', regexp_replace(coalesce("title", '') || ' ' || coalesce("body", ''), '[^[:alnum:]]+', ' ', 'g'))) stored,
373
- constraint "search_index_pkey" primary key ("tenant_id", "kind", "id")
428
+ constraint ${quoted(`${name}_pkey`)} primary key ("tenant_id", "kind", "id")
374
429
  )`,
375
- `create index if not exists "search_index_search_idx" on ${table} using gin ("search")`,
376
- `create index if not exists "search_index_recent_idx" on ${table} ("tenant_id", "indexed_at" desc)`
377
- ];
378
- var policy = (name, as, predicate) => [
430
+ index("search_idx", 'using gin ("search")'),
431
+ index("recent_idx", '("tenant_id", "indexed_at" desc)')
432
+ ];
433
+ };
434
+ var policy = (table, name, as, predicate) => [
379
435
  `drop policy if exists ${quoted(name)} on ${table}`,
380
436
  `create policy ${quoted(name)} on ${table} as ${as} for all ${both(predicate)}`
381
437
  ];
382
- var searchIndexPolicies = (settings) => [
383
- `alter table ${table} enable row level security`,
384
- `alter table ${table} force row level security`,
385
- ...policy(
386
- "tenant_isolation",
387
- "permissive",
388
- `"tenant_id" = (select ${setting(settings.tenantSetting)})`
389
- ),
390
- ...policy(
391
- "ops_maintenance",
392
- "permissive",
393
- `(select ${setting(settings.opsSetting)}) = '${settings.opsValue}'`
394
- )
395
- ];
396
- var renderMigrations = (settings) => [
397
- { name: "0001_search_index.sql", statements: searchIndexTable(settings.tenantSetting) },
398
- {
399
- name: "0002_search_index_policies.sql",
400
- statements: searchIndexPolicies({
401
- ...settings,
402
- opsValue: settings.opsValue ?? DEFAULT_OPS_VALUE
403
- })
404
- }
405
- ];
438
+ var searchIndexPolicies = (settings) => {
439
+ const table = quoted(settings.table);
440
+ return [
441
+ accepted(
442
+ `${RERUNNABLE} ${TIMEOUTS_BELONG_TO_THE_DEPLOYMENT}`,
443
+ ["prefer-robust-stmts", "require-lock-timeout", "require-statement-timeout"],
444
+ `alter table ${table} enable row level security`
445
+ ),
446
+ accepted(RERUNNABLE, ["prefer-robust-stmts"], `alter table ${table} force row level security`),
447
+ ...policy(
448
+ table,
449
+ "tenant_isolation",
450
+ "permissive",
451
+ `"tenant_id" = (select ${setting(settings.tenantSetting)})`
452
+ ),
453
+ ...policy(
454
+ table,
455
+ "ops_maintenance",
456
+ "permissive",
457
+ `(select ${setting(settings.opsSetting)}) = '${settings.opsValue}'`
458
+ )
459
+ ];
460
+ };
461
+ var renderMigrations = (settings) => {
462
+ const table = settings.table ?? SEARCH_INDEX_TABLE;
463
+ return [
464
+ { name: "0001_search_index.sql", statements: searchIndexTable(settings.tenantSetting, table) },
465
+ {
466
+ name: "0002_search_index_policies.sql",
467
+ statements: searchIndexPolicies({
468
+ ...settings,
469
+ opsValue: settings.opsValue ?? DEFAULT_OPS_VALUE,
470
+ table
471
+ })
472
+ }
473
+ ];
474
+ };
406
475
  var searchIndexMigrations = (settings) => renderMigrations(readSettings(settings));
407
476
 
408
477
  // src/provider/pg-fts.ts
409
478
  var import_conformance2 = require("@geonosis/conformance");
410
479
  var TOKEN = /[\p{L}\p{N}]+/gu;
411
480
  var toTsQuery = (terms) => (terms.match(TOKEN) ?? []).map((token) => `${token.toLowerCase()}:*`).join(" & ");
412
- var table2 = `"${SEARCH_INDEX_TABLE}"`;
413
481
  var COLUMN_OF = { kind: '"kind"' };
414
482
  var ORDERINGS = ["relevance", "recent"];
415
483
  var pgFtsSearchIndex = ({
416
484
  executor,
417
485
  nowMs,
418
- projections
486
+ projections,
487
+ table: named
419
488
  }) => {
489
+ const name = readTableName(named);
490
+ const table = `"${name}"`;
420
491
  const kinds = Object.keys(projections);
421
492
  const vocabulary = {
422
493
  filters: { kind: kinds },
@@ -443,7 +514,7 @@ var pgFtsSearchIndex = ({
443
514
  projection.body(document.fields),
444
515
  nowMs()
445
516
  ],
446
- text: `insert into ${table2} ("kind", "id", "title", "body", "indexed_at") values ($1, $2, $3, $4, $5) on conflict on constraint "search_index_pkey" do update set "title" = excluded."title", "body" = excluded."body", "indexed_at" = excluded."indexed_at"`
517
+ text: `insert into ${table} ("kind", "id", "title", "body", "indexed_at") values ($1, $2, $3, $4, $5) on conflict on constraint "${name}_pkey" do update set "title" = excluded."title", "body" = excluded."body", "indexed_at" = excluded."indexed_at"`
447
518
  });
448
519
  },
449
520
  query: async (terms, options) => {
@@ -453,10 +524,9 @@ var pgFtsSearchIndex = ({
453
524
  params.push(value);
454
525
  return `$${params.length}`;
455
526
  };
456
- const expression = toTsQuery(read.terms);
457
- const matched = expression === "" ? void 0 : bind(expression);
458
- const rank = matched === void 0 ? "0::real" : `ts_rank("search", to_tsquery('simple', ${matched}))`;
459
- const where = matched === void 0 ? [] : [`"search" @@ to_tsquery('simple', ${matched})`];
527
+ const matched = bind(toTsQuery(read.terms));
528
+ const rank = `ts_rank("search", to_tsquery('simple', ${matched}))`;
529
+ const where = [`"search" @@ to_tsquery('simple', ${matched})`];
460
530
  for (const filter of read.filters) {
461
531
  const column = COLUMN_OF[filter.name];
462
532
  if (column === void 0) {
@@ -473,7 +543,7 @@ var pgFtsSearchIndex = ({
473
543
  }
474
544
  const answer = await executor.execute({
475
545
  params,
476
- text: `select "kind", "id", "title", "indexed_at", ${rank} as "rank" from ${table2}${where.length === 0 ? "" : ` where ${where.join(" and ")}`} order by ${key} desc, "kind" desc, "id" desc limit ${bind(read.limit + 1)}`
546
+ text: `select "kind", "id", "title", "indexed_at", ${rank} as "rank" from ${table}${where.length === 0 ? "" : ` where ${where.join(" and ")}`} order by ${key} desc, "kind" desc, "id" desc limit ${bind(read.limit + 1)}`
477
547
  });
478
548
  const rows = (0, import_conformance2.rowsOf)(answer);
479
549
  const hits = rows.slice(0, read.limit).map((row) => ({
@@ -500,7 +570,7 @@ var pgFtsSearchIndex = ({
500
570
  retire: async (document) => {
501
571
  await executor.execute({
502
572
  params: [document.kind, document.id],
503
- text: `delete from ${table2} where "kind" = $1 and "id" = $2`
573
+ text: `delete from ${table} where "kind" = $1 and "id" = $2`
504
574
  });
505
575
  },
506
576
  vocabulary
@@ -513,6 +583,7 @@ var pgFtsSearchIndex = ({
513
583
  MIGRATION_PLACEHOLDERS,
514
584
  SEARCH_INDEX_TABLE,
515
585
  SearchRefusal,
586
+ carriesATerm,
516
587
  derivedProjectionConformance,
517
588
  encodeCursor,
518
589
  pgFtsSearchIndex,
package/dist/index.d.cts CHANGED
@@ -75,6 +75,8 @@ declare class SearchRefusal extends Error {
75
75
  constructor(message: string);
76
76
  }
77
77
  declare const DEFAULT_LIMIT = 50;
78
+ /** Whether anything in these terms could be matched: a run of letters or digits, anywhere in it. */
79
+ declare const carriesATerm: (terms: string) => boolean;
78
80
  type ReadFilter = {
79
81
  name: string;
80
82
  value: string;
@@ -178,6 +180,11 @@ type SearchIndexSettings = {
178
180
  opsSetting: string;
179
181
  /** What the ops setting holds while that lever is pulled. */
180
182
  opsValue?: string;
183
+ /**
184
+ * The table this index lives in. A consumer whose schema already holds a `search_index` names
185
+ * theirs here rather than dropping it (#244); every identifier the DDL builds is derived from it.
186
+ */
187
+ table?: string;
181
188
  /** What a transaction sets to name the tenant it is open for. */
182
189
  tenantSetting: string;
183
190
  };
@@ -186,9 +193,10 @@ type SearchMigration = {
186
193
  statements: string[];
187
194
  };
188
195
  declare const SEARCH_INDEX_TABLE = "search_index";
189
- /** The tokens the shipped `.sql` files carry where a session variable belongs. */
196
+ /** The tokens the shipped `.sql` files carry where a consumer's own name belongs. */
190
197
  declare const MIGRATION_PLACEHOLDERS: {
191
198
  opsSetting: string;
199
+ table: string;
192
200
  tenantSetting: string;
193
201
  };
194
202
  declare const DEFAULT_OPS_VALUE = "on";
@@ -205,6 +213,8 @@ type PgFtsOptions<Kind extends string> = {
205
213
  /** Injected, never ambient (D-050): the composition root passes `Date.now`. */
206
214
  nowMs: () => number;
207
215
  projections: SearchProjections<Kind>;
216
+ /** The table the migrations were rendered with (#244). `search_index` when nobody named one. */
217
+ table?: string;
208
218
  };
209
219
  /**
210
220
  * Postgres full text search over an injected executor.
@@ -213,6 +223,6 @@ type PgFtsOptions<Kind extends string> = {
213
223
  * the read carries no predicate at all — the policies in this package's own migrations are what
214
224
  * answers that question, so a query that forgot its session sees nothing rather than everything.
215
225
  */
216
- declare const pgFtsSearchIndex: <Kind extends string>({ executor, nowMs, projections, }: PgFtsOptions<Kind>) => SearchIndex<Kind>;
226
+ declare const pgFtsSearchIndex: <Kind extends string>({ executor, nowMs, projections, table: named, }: PgFtsOptions<Kind>) => SearchIndex<Kind>;
217
227
 
218
- export { DEFAULT_LIMIT, DEFAULT_OPS_VALUE, type DocumentAddress, type DocumentFields, type IndexedDocument, MIGRATION_PLACEHOLDERS, type PgFtsOptions, type ReadFilter, type ReadQuery, SEARCH_INDEX_TABLE, type SearchConformanceCase, type SearchCursor, type SearchCursorKey, type SearchExample, type SearchHit, type SearchIndex, type SearchIndexSettings, type SearchMigration, type SearchOptions, type SearchPage, type SearchProjection, type SearchProjections, SearchRefusal, type SearchSubject, type SearchVocabulary, derivedProjectionConformance, encodeCursor, pgFtsSearchIndex, readCursor, readSearchQuery, searchConformance, searchIndexMigrations, tenantIsolationConformance, vocabularyConformance };
228
+ export { DEFAULT_LIMIT, DEFAULT_OPS_VALUE, type DocumentAddress, type DocumentFields, type IndexedDocument, MIGRATION_PLACEHOLDERS, type PgFtsOptions, type ReadFilter, type ReadQuery, SEARCH_INDEX_TABLE, type SearchConformanceCase, type SearchCursor, type SearchCursorKey, type SearchExample, type SearchHit, type SearchIndex, type SearchIndexSettings, type SearchMigration, type SearchOptions, type SearchPage, type SearchProjection, type SearchProjections, SearchRefusal, type SearchSubject, type SearchVocabulary, carriesATerm, derivedProjectionConformance, encodeCursor, pgFtsSearchIndex, readCursor, readSearchQuery, searchConformance, searchIndexMigrations, tenantIsolationConformance, vocabularyConformance };
package/dist/index.d.ts CHANGED
@@ -75,6 +75,8 @@ declare class SearchRefusal extends Error {
75
75
  constructor(message: string);
76
76
  }
77
77
  declare const DEFAULT_LIMIT = 50;
78
+ /** Whether anything in these terms could be matched: a run of letters or digits, anywhere in it. */
79
+ declare const carriesATerm: (terms: string) => boolean;
78
80
  type ReadFilter = {
79
81
  name: string;
80
82
  value: string;
@@ -178,6 +180,11 @@ type SearchIndexSettings = {
178
180
  opsSetting: string;
179
181
  /** What the ops setting holds while that lever is pulled. */
180
182
  opsValue?: string;
183
+ /**
184
+ * The table this index lives in. A consumer whose schema already holds a `search_index` names
185
+ * theirs here rather than dropping it (#244); every identifier the DDL builds is derived from it.
186
+ */
187
+ table?: string;
181
188
  /** What a transaction sets to name the tenant it is open for. */
182
189
  tenantSetting: string;
183
190
  };
@@ -186,9 +193,10 @@ type SearchMigration = {
186
193
  statements: string[];
187
194
  };
188
195
  declare const SEARCH_INDEX_TABLE = "search_index";
189
- /** The tokens the shipped `.sql` files carry where a session variable belongs. */
196
+ /** The tokens the shipped `.sql` files carry where a consumer's own name belongs. */
190
197
  declare const MIGRATION_PLACEHOLDERS: {
191
198
  opsSetting: string;
199
+ table: string;
192
200
  tenantSetting: string;
193
201
  };
194
202
  declare const DEFAULT_OPS_VALUE = "on";
@@ -205,6 +213,8 @@ type PgFtsOptions<Kind extends string> = {
205
213
  /** Injected, never ambient (D-050): the composition root passes `Date.now`. */
206
214
  nowMs: () => number;
207
215
  projections: SearchProjections<Kind>;
216
+ /** The table the migrations were rendered with (#244). `search_index` when nobody named one. */
217
+ table?: string;
208
218
  };
209
219
  /**
210
220
  * Postgres full text search over an injected executor.
@@ -213,6 +223,6 @@ type PgFtsOptions<Kind extends string> = {
213
223
  * the read carries no predicate at all — the policies in this package's own migrations are what
214
224
  * answers that question, so a query that forgot its session sees nothing rather than everything.
215
225
  */
216
- declare const pgFtsSearchIndex: <Kind extends string>({ executor, nowMs, projections, }: PgFtsOptions<Kind>) => SearchIndex<Kind>;
226
+ declare const pgFtsSearchIndex: <Kind extends string>({ executor, nowMs, projections, table: named, }: PgFtsOptions<Kind>) => SearchIndex<Kind>;
217
227
 
218
- export { DEFAULT_LIMIT, DEFAULT_OPS_VALUE, type DocumentAddress, type DocumentFields, type IndexedDocument, MIGRATION_PLACEHOLDERS, type PgFtsOptions, type ReadFilter, type ReadQuery, SEARCH_INDEX_TABLE, type SearchConformanceCase, type SearchCursor, type SearchCursorKey, type SearchExample, type SearchHit, type SearchIndex, type SearchIndexSettings, type SearchMigration, type SearchOptions, type SearchPage, type SearchProjection, type SearchProjections, SearchRefusal, type SearchSubject, type SearchVocabulary, derivedProjectionConformance, encodeCursor, pgFtsSearchIndex, readCursor, readSearchQuery, searchConformance, searchIndexMigrations, tenantIsolationConformance, vocabularyConformance };
228
+ export { DEFAULT_LIMIT, DEFAULT_OPS_VALUE, type DocumentAddress, type DocumentFields, type IndexedDocument, MIGRATION_PLACEHOLDERS, type PgFtsOptions, type ReadFilter, type ReadQuery, SEARCH_INDEX_TABLE, type SearchConformanceCase, type SearchCursor, type SearchCursorKey, type SearchExample, type SearchHit, type SearchIndex, type SearchIndexSettings, type SearchMigration, type SearchOptions, type SearchPage, type SearchProjection, type SearchProjections, SearchRefusal, type SearchSubject, type SearchVocabulary, carriesATerm, derivedProjectionConformance, encodeCursor, pgFtsSearchIndex, readCursor, readSearchQuery, searchConformance, searchIndexMigrations, tenantIsolationConformance, vocabularyConformance };
package/dist/index.js CHANGED
@@ -1,7 +1,98 @@
1
1
  // src/conformance.ts
2
2
  import { assertRefuses, assertThat, ConformanceFailure } from "@geonosis/conformance";
3
+
4
+ // src/contract.ts
5
+ var SearchRefusal = class extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "SearchRefusal";
9
+ }
10
+ };
11
+ var NOT_ZERO = "an empty answer to this is not a count of zero \u2014 it is a question nothing could match, and it is refused rather than answered";
12
+ var DEFAULT_LIMIT = 50;
13
+ var TERM = /[\p{L}\p{N}]/u;
14
+ var carriesATerm = (terms) => TERM.test(terms);
15
+ var readSearchQuery = (vocabulary, terms, options = {}) => {
16
+ if (!carriesATerm(terms)) {
17
+ throw new SearchRefusal(
18
+ `"${terms}" holds no letter or digit, so there is nothing in it to match: every row this index has matches it equally, and the page that comes back is a full scan wearing the shape of a search. A caller who wants the rows themselves is asking for a listing, which is a different question`
19
+ );
20
+ }
21
+ const orderings = vocabulary.orderings;
22
+ const first = orderings[0];
23
+ if (first === void 0) {
24
+ throw new SearchRefusal(
25
+ "this index declares no ordering, and a page of results is a sequence: declare at least one, because unordered rows cannot be paged by their key at all"
26
+ );
27
+ }
28
+ const order = options.order ?? first;
29
+ if (!orderings.includes(order)) {
30
+ throw new SearchRefusal(
31
+ `"${order}" is not an ordering this index declares \u2014 it orders by ${[...orderings].toSorted().join(", ")}. ${NOT_ZERO}`
32
+ );
33
+ }
34
+ const filters = Object.entries(options.filters ?? {}).map(([name, value]) => {
35
+ const values = vocabulary.filters[name];
36
+ if (values === void 0) {
37
+ throw new SearchRefusal(
38
+ `"${name}" is not a filter this index declares \u2014 it filters by ${Object.keys(vocabulary.filters).toSorted().join(", ")}. ${NOT_ZERO}`
39
+ );
40
+ }
41
+ if (!values.includes(value)) {
42
+ throw new SearchRefusal(
43
+ `"${name}" takes ${[...values].toSorted().join(", ")}, and was asked for "${value}". ${NOT_ZERO}`
44
+ );
45
+ }
46
+ return { name, value };
47
+ });
48
+ return {
49
+ ...options.after === void 0 ? {} : { after: readCursor(options.after, { order, terms }) },
50
+ filters,
51
+ limit: options.limit ?? DEFAULT_LIMIT,
52
+ order,
53
+ terms
54
+ };
55
+ };
56
+ var encodeCursor = (key) => encodeURIComponent(JSON.stringify(key));
57
+ var KEYS = ["at", "id", "kind", "order", "rank", "terms"];
58
+ var parsed = (cursor) => {
59
+ let value;
60
+ try {
61
+ value = JSON.parse(decodeURIComponent(cursor));
62
+ } catch {
63
+ throw new SearchRefusal(
64
+ "this cursor was not minted by this index: a page is reached by the key of the row before it, and that key is not readable here"
65
+ );
66
+ }
67
+ if (typeof value !== "object" || value === null) {
68
+ throw new SearchRefusal("this cursor holds no key at all");
69
+ }
70
+ const held = value;
71
+ const missing = KEYS.filter((key) => held[key] === void 0);
72
+ if (missing.length > 0) {
73
+ throw new SearchRefusal(`this cursor is missing ${missing.join(", ")}`);
74
+ }
75
+ return held;
76
+ };
77
+ var readCursor = (cursor, asked) => {
78
+ const key = parsed(cursor);
79
+ if (key.terms !== asked.terms) {
80
+ throw new SearchRefusal(
81
+ `this cursor was minted for "${key.terms}" and the query asks "${asked.terms}": ranks are comparable only within one question, so the page after it would be another query's`
82
+ );
83
+ }
84
+ if (key.order !== asked.order) {
85
+ throw new SearchRefusal(
86
+ `this cursor was minted under the "${key.order}" ordering and the query asks for "${asked.order}"`
87
+ );
88
+ }
89
+ return key;
90
+ };
91
+
92
+ // src/conformance.ts
3
93
  var A = "tenant_conformance_a";
4
94
  var B = "tenant_conformance_b";
95
+ var TERMLESS = "?!?";
5
96
  var twoExamples = (subject) => {
6
97
  const [first, second] = subject.examples;
7
98
  if (first === void 0 || second === void 0) {
@@ -45,7 +136,8 @@ var vocabularyConformance = (subject) => {
45
136
  A,
46
137
  (index) => assertRefuses(
47
138
  () => index.query(first.findableBy, { filters: { conformance_unknown: "x" } }),
48
- "a filter no vocabulary declares was answered rather than refused, and the empty answer it gives reads as a count of zero"
139
+ "a filter no vocabulary declares was answered rather than refused, and the empty answer it gives reads as a count of zero",
140
+ SearchRefusal
49
141
  )
50
142
  );
51
143
  }
@@ -59,7 +151,22 @@ var vocabularyConformance = (subject) => {
59
151
  () => index.query(first.findableBy, {
60
152
  filters: { [filter.name]: "conformance_undeclared_value" }
61
153
  }),
62
- `"${filter.name}" takes ${[...filter.values].toSorted().join(", ")} and answered a value outside that list: publishing filter names without their values is how a caller filters for something nothing emits and reports the empty result as a count`
154
+ `"${filter.name}" takes ${[...filter.values].toSorted().join(", ")} and answered a value outside that list: publishing filter names without their values is how a caller filters for something nothing emits and reports the empty result as a count`,
155
+ SearchRefusal
156
+ );
157
+ });
158
+ }
159
+ },
160
+ {
161
+ name: "a query with no term in it is refused rather than answered with every row",
162
+ run: async () => {
163
+ await subject.reset();
164
+ await subject.inTenant(A, async (index) => {
165
+ await index.index(first.document);
166
+ await assertRefuses(
167
+ () => index.query(TERMLESS),
168
+ `"${TERMLESS}" carries nothing to match on, and the answer to it was a page rather than a refusal: a query whose terms hold no letter or digit selects every row this index has, which is a full scan wearing the shape of a search`,
169
+ SearchRefusal
63
170
  );
64
171
  });
65
172
  }
@@ -71,7 +178,8 @@ var vocabularyConformance = (subject) => {
71
178
  A,
72
179
  (index) => assertRefuses(
73
180
  () => index.query(first.findableBy, { order: "conformance_unknown" }),
74
- "an ordering nobody declared was accepted, so the rows came back in whatever order the storage felt like and the page after them is unreachable"
181
+ "an ordering nobody declared was accepted, so the rows came back in whatever order the storage felt like and the page after them is unreachable",
182
+ SearchRefusal
75
183
  )
76
184
  );
77
185
  }
@@ -213,95 +321,25 @@ var searchConformance = (subject) => [
213
321
  ...tenantIsolationConformance(subject)
214
322
  ];
215
323
 
216
- // src/contract.ts
217
- var SearchRefusal = class extends Error {
218
- constructor(message) {
219
- super(message);
220
- this.name = "SearchRefusal";
221
- }
222
- };
223
- var NOT_ZERO = "an empty answer to this is not a count of zero \u2014 it is a question nothing could match, and it is refused rather than answered";
224
- var DEFAULT_LIMIT = 50;
225
- var readSearchQuery = (vocabulary, terms, options = {}) => {
226
- const orderings = vocabulary.orderings;
227
- const first = orderings[0];
228
- if (first === void 0) {
229
- throw new SearchRefusal(
230
- "this index declares no ordering, and a page of results is a sequence: declare at least one, because unordered rows cannot be paged by their key at all"
231
- );
232
- }
233
- const order = options.order ?? first;
234
- if (!orderings.includes(order)) {
235
- throw new SearchRefusal(
236
- `"${order}" is not an ordering this index declares \u2014 it orders by ${[...orderings].toSorted().join(", ")}. ${NOT_ZERO}`
237
- );
238
- }
239
- const filters = Object.entries(options.filters ?? {}).map(([name, value]) => {
240
- const values = vocabulary.filters[name];
241
- if (values === void 0) {
242
- throw new SearchRefusal(
243
- `"${name}" is not a filter this index declares \u2014 it filters by ${Object.keys(vocabulary.filters).toSorted().join(", ")}. ${NOT_ZERO}`
244
- );
245
- }
246
- if (!values.includes(value)) {
247
- throw new SearchRefusal(
248
- `"${name}" takes ${[...values].toSorted().join(", ")}, and was asked for "${value}". ${NOT_ZERO}`
249
- );
250
- }
251
- return { name, value };
252
- });
253
- return {
254
- ...options.after === void 0 ? {} : { after: readCursor(options.after, { order, terms }) },
255
- filters,
256
- limit: options.limit ?? DEFAULT_LIMIT,
257
- order,
258
- terms
259
- };
260
- };
261
- var encodeCursor = (key) => encodeURIComponent(JSON.stringify(key));
262
- var KEYS = ["at", "id", "kind", "order", "rank", "terms"];
263
- var parsed = (cursor) => {
264
- let value;
265
- try {
266
- value = JSON.parse(decodeURIComponent(cursor));
267
- } catch {
268
- throw new SearchRefusal(
269
- "this cursor was not minted by this index: a page is reached by the key of the row before it, and that key is not readable here"
270
- );
271
- }
272
- if (typeof value !== "object" || value === null) {
273
- throw new SearchRefusal("this cursor holds no key at all");
274
- }
275
- const held = value;
276
- const missing = KEYS.filter((key) => held[key] === void 0);
277
- if (missing.length > 0) {
278
- throw new SearchRefusal(`this cursor is missing ${missing.join(", ")}`);
279
- }
280
- return held;
281
- };
282
- var readCursor = (cursor, asked) => {
283
- const key = parsed(cursor);
284
- if (key.terms !== asked.terms) {
285
- throw new SearchRefusal(
286
- `this cursor was minted for "${key.terms}" and the query asks "${asked.terms}": ranks are comparable only within one question, so the page after it would be another query's`
287
- );
288
- }
289
- if (key.order !== asked.order) {
290
- throw new SearchRefusal(
291
- `this cursor was minted under the "${key.order}" ordering and the query asks for "${asked.order}"`
292
- );
293
- }
294
- return key;
295
- };
296
-
297
324
  // src/provider/ddl.ts
298
325
  var SEARCH_INDEX_TABLE = "search_index";
299
326
  var MIGRATION_PLACEHOLDERS = {
300
327
  opsSetting: "{{opsSetting}}",
328
+ table: "{{table}}",
301
329
  tenantSetting: "{{tenantSetting}}"
302
330
  };
303
331
  var DEFAULT_OPS_VALUE = "on";
304
332
  var CUSTOM_SETTING = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
333
+ var TABLE_NAME = /^[A-Za-z_][A-Za-z0-9_$]*$/;
334
+ var readTableName = (name) => {
335
+ const table = name ?? SEARCH_INDEX_TABLE;
336
+ if (!TABLE_NAME.test(table)) {
337
+ throw new SearchRefusal(
338
+ `"${table}" is not a table name this DDL can write: it is inlined into every statement inside double quotes, and a name outside \`letter_or_underscore followed by letters, digits, underscores or $\` could close them. Name the schema in the search_path rather than here`
339
+ );
340
+ }
341
+ return table;
342
+ };
305
343
  var readSettings = (settings) => {
306
344
  for (const named of ["opsSetting", "tenantSetting"]) {
307
345
  const name = settings[named];
@@ -316,14 +354,30 @@ var readSettings = (settings) => {
316
354
  );
317
355
  }
318
356
  }
319
- return { ...settings, opsValue: settings.opsValue ?? DEFAULT_OPS_VALUE };
357
+ return {
358
+ ...settings,
359
+ opsValue: settings.opsValue ?? DEFAULT_OPS_VALUE,
360
+ table: readTableName(settings.table)
361
+ };
320
362
  };
321
363
  var quoted = (identifier) => `"${identifier}"`;
322
364
  var setting = (name) => `current_setting('${name}', true)`;
323
365
  var both = (predicate) => `using (${predicate}) with check (${predicate})`;
324
- var table = quoted(SEARCH_INDEX_TABLE);
325
- var searchIndexTable = (tenantSetting) => [
326
- `create table if not exists ${table} (
366
+ var accepted = (because, rules, statement) => `-- ${because}
367
+ -- squawk-ignore ${rules.join(", ")}
368
+ ${statement}`;
369
+ var TIMEOUTS_BELONG_TO_THE_DEPLOYMENT = "lock_timeout and statement_timeout are one deployment\u2019s numbers, and a value written into shipped DDL imposes that deployment\u2019s budget on every consumer: set them in the session that migrates.";
370
+ var IN_ONE_TRANSACTION = "The migrator applies this file inside a transaction, which is also why CONCURRENTLY is not available here; the index is created in the same migration as its table, over no rows.";
371
+ var RERUNNABLE = "This file is applied inside a transaction and every statement in it is idempotent, so a run that fails part way through leaves nothing behind and a rerun is a no-op.";
372
+ var searchIndexTable = (tenantSetting, name) => {
373
+ const table = quoted(name);
374
+ const index = (suffix, over) => accepted(
375
+ `${IN_ONE_TRANSACTION} ${TIMEOUTS_BELONG_TO_THE_DEPLOYMENT}`,
376
+ ["require-concurrent-index-creation", "require-lock-timeout", "require-statement-timeout"],
377
+ `create index if not exists ${quoted(`${name}_${suffix}`)} on ${table} ${over}`
378
+ );
379
+ return [
380
+ `create table if not exists ${table} (
327
381
  "tenant_id" text not null default ${setting(tenantSetting)},
328
382
  "kind" text not null,
329
383
  "id" text not null,
@@ -331,53 +385,69 @@ var searchIndexTable = (tenantSetting) => [
331
385
  "body" text not null,
332
386
  "indexed_at" bigint not null,
333
387
  "search" tsvector generated always as (to_tsvector('simple', regexp_replace(coalesce("title", '') || ' ' || coalesce("body", ''), '[^[:alnum:]]+', ' ', 'g'))) stored,
334
- constraint "search_index_pkey" primary key ("tenant_id", "kind", "id")
388
+ constraint ${quoted(`${name}_pkey`)} primary key ("tenant_id", "kind", "id")
335
389
  )`,
336
- `create index if not exists "search_index_search_idx" on ${table} using gin ("search")`,
337
- `create index if not exists "search_index_recent_idx" on ${table} ("tenant_id", "indexed_at" desc)`
338
- ];
339
- var policy = (name, as, predicate) => [
390
+ index("search_idx", 'using gin ("search")'),
391
+ index("recent_idx", '("tenant_id", "indexed_at" desc)')
392
+ ];
393
+ };
394
+ var policy = (table, name, as, predicate) => [
340
395
  `drop policy if exists ${quoted(name)} on ${table}`,
341
396
  `create policy ${quoted(name)} on ${table} as ${as} for all ${both(predicate)}`
342
397
  ];
343
- var searchIndexPolicies = (settings) => [
344
- `alter table ${table} enable row level security`,
345
- `alter table ${table} force row level security`,
346
- ...policy(
347
- "tenant_isolation",
348
- "permissive",
349
- `"tenant_id" = (select ${setting(settings.tenantSetting)})`
350
- ),
351
- ...policy(
352
- "ops_maintenance",
353
- "permissive",
354
- `(select ${setting(settings.opsSetting)}) = '${settings.opsValue}'`
355
- )
356
- ];
357
- var renderMigrations = (settings) => [
358
- { name: "0001_search_index.sql", statements: searchIndexTable(settings.tenantSetting) },
359
- {
360
- name: "0002_search_index_policies.sql",
361
- statements: searchIndexPolicies({
362
- ...settings,
363
- opsValue: settings.opsValue ?? DEFAULT_OPS_VALUE
364
- })
365
- }
366
- ];
398
+ var searchIndexPolicies = (settings) => {
399
+ const table = quoted(settings.table);
400
+ return [
401
+ accepted(
402
+ `${RERUNNABLE} ${TIMEOUTS_BELONG_TO_THE_DEPLOYMENT}`,
403
+ ["prefer-robust-stmts", "require-lock-timeout", "require-statement-timeout"],
404
+ `alter table ${table} enable row level security`
405
+ ),
406
+ accepted(RERUNNABLE, ["prefer-robust-stmts"], `alter table ${table} force row level security`),
407
+ ...policy(
408
+ table,
409
+ "tenant_isolation",
410
+ "permissive",
411
+ `"tenant_id" = (select ${setting(settings.tenantSetting)})`
412
+ ),
413
+ ...policy(
414
+ table,
415
+ "ops_maintenance",
416
+ "permissive",
417
+ `(select ${setting(settings.opsSetting)}) = '${settings.opsValue}'`
418
+ )
419
+ ];
420
+ };
421
+ var renderMigrations = (settings) => {
422
+ const table = settings.table ?? SEARCH_INDEX_TABLE;
423
+ return [
424
+ { name: "0001_search_index.sql", statements: searchIndexTable(settings.tenantSetting, table) },
425
+ {
426
+ name: "0002_search_index_policies.sql",
427
+ statements: searchIndexPolicies({
428
+ ...settings,
429
+ opsValue: settings.opsValue ?? DEFAULT_OPS_VALUE,
430
+ table
431
+ })
432
+ }
433
+ ];
434
+ };
367
435
  var searchIndexMigrations = (settings) => renderMigrations(readSettings(settings));
368
436
 
369
437
  // src/provider/pg-fts.ts
370
438
  import { rowsOf } from "@geonosis/conformance";
371
439
  var TOKEN = /[\p{L}\p{N}]+/gu;
372
440
  var toTsQuery = (terms) => (terms.match(TOKEN) ?? []).map((token) => `${token.toLowerCase()}:*`).join(" & ");
373
- var table2 = `"${SEARCH_INDEX_TABLE}"`;
374
441
  var COLUMN_OF = { kind: '"kind"' };
375
442
  var ORDERINGS = ["relevance", "recent"];
376
443
  var pgFtsSearchIndex = ({
377
444
  executor,
378
445
  nowMs,
379
- projections
446
+ projections,
447
+ table: named
380
448
  }) => {
449
+ const name = readTableName(named);
450
+ const table = `"${name}"`;
381
451
  const kinds = Object.keys(projections);
382
452
  const vocabulary = {
383
453
  filters: { kind: kinds },
@@ -404,7 +474,7 @@ var pgFtsSearchIndex = ({
404
474
  projection.body(document.fields),
405
475
  nowMs()
406
476
  ],
407
- text: `insert into ${table2} ("kind", "id", "title", "body", "indexed_at") values ($1, $2, $3, $4, $5) on conflict on constraint "search_index_pkey" do update set "title" = excluded."title", "body" = excluded."body", "indexed_at" = excluded."indexed_at"`
477
+ text: `insert into ${table} ("kind", "id", "title", "body", "indexed_at") values ($1, $2, $3, $4, $5) on conflict on constraint "${name}_pkey" do update set "title" = excluded."title", "body" = excluded."body", "indexed_at" = excluded."indexed_at"`
408
478
  });
409
479
  },
410
480
  query: async (terms, options) => {
@@ -414,10 +484,9 @@ var pgFtsSearchIndex = ({
414
484
  params.push(value);
415
485
  return `$${params.length}`;
416
486
  };
417
- const expression = toTsQuery(read.terms);
418
- const matched = expression === "" ? void 0 : bind(expression);
419
- const rank = matched === void 0 ? "0::real" : `ts_rank("search", to_tsquery('simple', ${matched}))`;
420
- const where = matched === void 0 ? [] : [`"search" @@ to_tsquery('simple', ${matched})`];
487
+ const matched = bind(toTsQuery(read.terms));
488
+ const rank = `ts_rank("search", to_tsquery('simple', ${matched}))`;
489
+ const where = [`"search" @@ to_tsquery('simple', ${matched})`];
421
490
  for (const filter of read.filters) {
422
491
  const column = COLUMN_OF[filter.name];
423
492
  if (column === void 0) {
@@ -434,7 +503,7 @@ var pgFtsSearchIndex = ({
434
503
  }
435
504
  const answer = await executor.execute({
436
505
  params,
437
- text: `select "kind", "id", "title", "indexed_at", ${rank} as "rank" from ${table2}${where.length === 0 ? "" : ` where ${where.join(" and ")}`} order by ${key} desc, "kind" desc, "id" desc limit ${bind(read.limit + 1)}`
506
+ text: `select "kind", "id", "title", "indexed_at", ${rank} as "rank" from ${table}${where.length === 0 ? "" : ` where ${where.join(" and ")}`} order by ${key} desc, "kind" desc, "id" desc limit ${bind(read.limit + 1)}`
438
507
  });
439
508
  const rows = rowsOf(answer);
440
509
  const hits = rows.slice(0, read.limit).map((row) => ({
@@ -461,7 +530,7 @@ var pgFtsSearchIndex = ({
461
530
  retire: async (document) => {
462
531
  await executor.execute({
463
532
  params: [document.kind, document.id],
464
- text: `delete from ${table2} where "kind" = $1 and "id" = $2`
533
+ text: `delete from ${table} where "kind" = $1 and "id" = $2`
465
534
  });
466
535
  },
467
536
  vocabulary
@@ -473,6 +542,7 @@ export {
473
542
  MIGRATION_PLACEHOLDERS,
474
543
  SEARCH_INDEX_TABLE,
475
544
  SearchRefusal,
545
+ carriesATerm,
476
546
  derivedProjectionConformance,
477
547
  encodeCursor,
478
548
  pgFtsSearchIndex,
@@ -1,4 +1,4 @@
1
- create table if not exists "search_index" (
1
+ create table if not exists "{{table}}" (
2
2
  "tenant_id" text not null default current_setting('{{tenantSetting}}', true),
3
3
  "kind" text not null,
4
4
  "id" text not null,
@@ -6,9 +6,13 @@ create table if not exists "search_index" (
6
6
  "body" text not null,
7
7
  "indexed_at" bigint not null,
8
8
  "search" tsvector generated always as (to_tsvector('simple', regexp_replace(coalesce("title", '') || ' ' || coalesce("body", ''), '[^[:alnum:]]+', ' ', 'g'))) stored,
9
- constraint "search_index_pkey" primary key ("tenant_id", "kind", "id")
9
+ constraint "{{table}}_pkey" primary key ("tenant_id", "kind", "id")
10
10
  );
11
11
 
12
- create index if not exists "search_index_search_idx" on "search_index" using gin ("search");
12
+ -- The migrator applies this file inside a transaction, which is also why CONCURRENTLY is not available here; the index is created in the same migration as its table, over no rows. lock_timeout and statement_timeout are one deployment’s numbers, and a value written into shipped DDL imposes that deployment’s budget on every consumer: set them in the session that migrates.
13
+ -- squawk-ignore require-concurrent-index-creation, require-lock-timeout, require-statement-timeout
14
+ create index if not exists "{{table}}_search_idx" on "{{table}}" using gin ("search");
13
15
 
14
- create index if not exists "search_index_recent_idx" on "search_index" ("tenant_id", "indexed_at" desc);
16
+ -- The migrator applies this file inside a transaction, which is also why CONCURRENTLY is not available here; the index is created in the same migration as its table, over no rows. lock_timeout and statement_timeout are one deployment’s numbers, and a value written into shipped DDL imposes that deployment’s budget on every consumer: set them in the session that migrates.
17
+ -- squawk-ignore require-concurrent-index-creation, require-lock-timeout, require-statement-timeout
18
+ create index if not exists "{{table}}_recent_idx" on "{{table}}" ("tenant_id", "indexed_at" desc);
@@ -1,11 +1,15 @@
1
- alter table "search_index" enable row level security;
1
+ -- This file is applied inside a transaction and every statement in it is idempotent, so a run that fails part way through leaves nothing behind and a rerun is a no-op. lock_timeout and statement_timeout are one deployment’s numbers, and a value written into shipped DDL imposes that deployment’s budget on every consumer: set them in the session that migrates.
2
+ -- squawk-ignore prefer-robust-stmts, require-lock-timeout, require-statement-timeout
3
+ alter table "{{table}}" enable row level security;
2
4
 
3
- alter table "search_index" force row level security;
5
+ -- This file is applied inside a transaction and every statement in it is idempotent, so a run that fails part way through leaves nothing behind and a rerun is a no-op.
6
+ -- squawk-ignore prefer-robust-stmts
7
+ alter table "{{table}}" force row level security;
4
8
 
5
- drop policy if exists "tenant_isolation" on "search_index";
9
+ drop policy if exists "tenant_isolation" on "{{table}}";
6
10
 
7
- create policy "tenant_isolation" on "search_index" as permissive for all using ("tenant_id" = (select current_setting('{{tenantSetting}}', true))) with check ("tenant_id" = (select current_setting('{{tenantSetting}}', true)));
11
+ create policy "tenant_isolation" on "{{table}}" as permissive for all using ("tenant_id" = (select current_setting('{{tenantSetting}}', true))) with check ("tenant_id" = (select current_setting('{{tenantSetting}}', true)));
8
12
 
9
- drop policy if exists "ops_maintenance" on "search_index";
13
+ drop policy if exists "ops_maintenance" on "{{table}}";
10
14
 
11
- create policy "ops_maintenance" on "search_index" as permissive for all using ((select current_setting('{{opsSetting}}', true)) = 'on') with check ((select current_setting('{{opsSetting}}', true)) = 'on');
15
+ create policy "ops_maintenance" on "{{table}}" as permissive for all using ((select current_setting('{{opsSetting}}', true)) = 'on') with check ((select current_setting('{{opsSetting}}', true)) = 'on');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/search",
3
- "version": "0.1.1",
3
+ "version": "0.3.3",
4
4
  "types": "./dist/index.d.ts",
5
5
  "description": "Full-text search as a domain: an index whose write path derives its own searchable projection, queries answered only inside a declared vocabulary with keyset cursors, and a Postgres provider that runs over an injected executor and ships its DDL as migrations.",
6
6
  "keywords": [
@@ -34,12 +34,13 @@
34
34
  }
35
35
  },
36
36
  "files": [
37
+ "CHANGELOG.md",
37
38
  "dist",
38
39
  "migrations"
39
40
  ],
40
41
  "dependencies": {
41
- "@geonosis/conformance": "^0.2.0",
42
- "@geonosis/db": "^0.2.0"
42
+ "@geonosis/conformance": "^0.2.1",
43
+ "@geonosis/db": "^0.5.1"
43
44
  },
44
45
  "devDependencies": {
45
46
  "@types/pg": "^8.15.5",