@geonosis/search 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -9
- package/dist/index.cjs +197 -126
- package/dist/index.d.cts +13 -3
- package/dist/index.d.ts +13 -3
- package/dist/index.js +196 -126
- package/migrations/0001_search_index.sql +8 -4
- package/migrations/0002_search_index_policies.sql +10 -6
- package/package.json +2 -2
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 }`.
|
|
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}}
|
|
86
|
-
migrator that templates. For one that does not,
|
|
87
|
-
DDL rendered, as `SearchMigration[]`
|
|
88
|
-
whose names are not the `prefix.name`
|
|
89
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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 {
|
|
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
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
428
|
+
constraint ${quoted(`${name}_pkey`)} primary key ("tenant_id", "kind", "id")
|
|
374
429
|
)`,
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
];
|
|
378
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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 ${
|
|
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
|
|
457
|
-
const
|
|
458
|
-
const
|
|
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 ${
|
|
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 ${
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
388
|
+
constraint ${quoted(`${name}_pkey`)} primary key ("tenant_id", "kind", "id")
|
|
335
389
|
)`,
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
];
|
|
339
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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 ${
|
|
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
|
|
418
|
-
const
|
|
419
|
-
const
|
|
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 ${
|
|
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 ${
|
|
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 "
|
|
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 "
|
|
9
|
+
constraint "{{table}}_pkey" primary key ("tenant_id", "kind", "id")
|
|
10
10
|
);
|
|
11
11
|
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
9
|
+
drop policy if exists "tenant_isolation" on "{{table}}";
|
|
6
10
|
|
|
7
|
-
create policy "tenant_isolation" on "
|
|
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 "
|
|
13
|
+
drop policy if exists "ops_maintenance" on "{{table}}";
|
|
10
14
|
|
|
11
|
-
create policy "ops_maintenance" 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.
|
|
3
|
+
"version": "0.3.0",
|
|
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": [
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
],
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@geonosis/conformance": "^0.2.0",
|
|
42
|
-
"@geonosis/db": "^0.
|
|
42
|
+
"@geonosis/db": "^0.3.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/pg": "^8.15.5",
|