@geonosis/search 0.1.1
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/LICENSE +202 -0
- package/README.md +137 -0
- package/dist/index.cjs +525 -0
- package/dist/index.d.cts +218 -0
- package/dist/index.d.ts +218 -0
- package/dist/index.js +485 -0
- package/migrations/0001_search_index.sql +14 -0
- package/migrations/0002_search_index_policies.sql +11 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
// src/conformance.ts
|
|
2
|
+
import { assertRefuses, assertThat, ConformanceFailure } from "@geonosis/conformance";
|
|
3
|
+
var A = "tenant_conformance_a";
|
|
4
|
+
var B = "tenant_conformance_b";
|
|
5
|
+
var twoExamples = (subject) => {
|
|
6
|
+
const [first, second] = subject.examples;
|
|
7
|
+
if (first === void 0 || second === void 0) {
|
|
8
|
+
throw new ConformanceFailure(
|
|
9
|
+
"this subject offers fewer than two examples, and the tenant wall is a claim about one tenant\u2019s document being invisible to another: supply two documents with different ids"
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return [first, second];
|
|
13
|
+
};
|
|
14
|
+
var found = (hits, document) => hits.some((hit) => hit.id === document.id && hit.kind === document.kind);
|
|
15
|
+
var anyFilter = (index) => {
|
|
16
|
+
const [name] = Object.keys(index.vocabulary.filters);
|
|
17
|
+
if (name === void 0) {
|
|
18
|
+
throw new ConformanceFailure(
|
|
19
|
+
"this index declares no filter at all, so nothing here can ask it for one it does not have \u2014 a vocabulary with no names is not a vocabulary"
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return { name, values: index.vocabulary.filters[name] ?? [] };
|
|
23
|
+
};
|
|
24
|
+
var vocabularyConformance = (subject) => {
|
|
25
|
+
const [first] = twoExamples(subject);
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
name: "a query inside the declared vocabulary is answered",
|
|
29
|
+
run: async () => {
|
|
30
|
+
await subject.reset();
|
|
31
|
+
await subject.inTenant(A, async (index) => {
|
|
32
|
+
await index.index(first.document);
|
|
33
|
+
const page = await index.query(first.findableBy);
|
|
34
|
+
assertThat(
|
|
35
|
+
found(page.hits, first.document),
|
|
36
|
+
"a document this index took cannot be found by a word its own projection derives \u2014 every refusal this exam makes below would be an index that refuses everything"
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "a filter this index never declared is refused",
|
|
43
|
+
run: async () => {
|
|
44
|
+
await subject.inTenant(
|
|
45
|
+
A,
|
|
46
|
+
(index) => assertRefuses(
|
|
47
|
+
() => 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"
|
|
49
|
+
)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "a value that filter does not take is refused",
|
|
55
|
+
run: async () => {
|
|
56
|
+
await subject.inTenant(A, async (index) => {
|
|
57
|
+
const filter = anyFilter(index);
|
|
58
|
+
await assertRefuses(
|
|
59
|
+
() => index.query(first.findableBy, {
|
|
60
|
+
filters: { [filter.name]: "conformance_undeclared_value" }
|
|
61
|
+
}),
|
|
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`
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "an ordering this index never declared is refused",
|
|
69
|
+
run: async () => {
|
|
70
|
+
await subject.inTenant(
|
|
71
|
+
A,
|
|
72
|
+
(index) => assertRefuses(
|
|
73
|
+
() => 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"
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "every filter it declares publishes the values it takes",
|
|
81
|
+
run: async () => {
|
|
82
|
+
await subject.inTenant(A, (index) => {
|
|
83
|
+
const empty = Object.entries(index.vocabulary.filters).filter(([, values]) => values.length === 0).map(([name]) => name);
|
|
84
|
+
assertThat(
|
|
85
|
+
empty.length === 0,
|
|
86
|
+
`${empty.join(", ")} is published as a filter with no values, which is exactly the catalog that gets asked for a value nothing emits`
|
|
87
|
+
);
|
|
88
|
+
return Promise.resolve();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
];
|
|
93
|
+
};
|
|
94
|
+
var derivedProjectionConformance = (subject) => {
|
|
95
|
+
const [first] = twoExamples(subject);
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
name: "a document is findable by the text its projection derives",
|
|
99
|
+
run: async () => {
|
|
100
|
+
await subject.reset();
|
|
101
|
+
await subject.inTenant(A, async (index) => {
|
|
102
|
+
await index.index(first.document);
|
|
103
|
+
assertThat(
|
|
104
|
+
found((await index.query(first.findableBy)).hits, first.document),
|
|
105
|
+
`a document indexed under "${first.findableBy}" cannot be found by it`
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "and not by a value its projection never read",
|
|
112
|
+
run: async () => {
|
|
113
|
+
await subject.reset();
|
|
114
|
+
await subject.inTenant(A, async (index) => {
|
|
115
|
+
await index.index(first.document);
|
|
116
|
+
const page = await index.query(first.notFindableBy);
|
|
117
|
+
assertThat(
|
|
118
|
+
!found(page.hits, first.document),
|
|
119
|
+
`"${first.notFindableBy}" is carried by the document and read by no projection, and it found the row: this index stored the record it was handed rather than the text its projection derives, so what is searchable is whatever each call site remembered to fill`
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "indexing the same document again replaces the row rather than adding one",
|
|
126
|
+
run: async () => {
|
|
127
|
+
await subject.reset();
|
|
128
|
+
await subject.inTenant(A, async (index) => {
|
|
129
|
+
await index.index(first.document);
|
|
130
|
+
await index.index(first.document);
|
|
131
|
+
const page = await index.query(first.findableBy);
|
|
132
|
+
assertThat(
|
|
133
|
+
page.hits.filter((hit) => hit.id === first.document.id).length === 1,
|
|
134
|
+
"the same document was indexed twice and is found twice: every edit of a document leaves another copy of it in the results"
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: "a retired document is not findable",
|
|
141
|
+
run: async () => {
|
|
142
|
+
await subject.reset();
|
|
143
|
+
await subject.inTenant(A, async (index) => {
|
|
144
|
+
await index.index(first.document);
|
|
145
|
+
await index.retire({ id: first.document.id, kind: first.document.kind });
|
|
146
|
+
assertThat(
|
|
147
|
+
!found((await index.query(first.findableBy)).hits, first.document),
|
|
148
|
+
"a retired document is still in the results, so deleting something leaves it findable"
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
];
|
|
154
|
+
};
|
|
155
|
+
var outsideOf = (subject) => {
|
|
156
|
+
const outside = subject.outsideAnySession;
|
|
157
|
+
if (typeof outside !== "function") {
|
|
158
|
+
throw new ConformanceFailure(
|
|
159
|
+
'this subject has no `outsideAnySession`, and "a read that names no tenant sees nothing" is a claim about what the policies alone let through: supply the index over a handle outside every session \u2014 or this claim is untested rather than passing'
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return outside;
|
|
163
|
+
};
|
|
164
|
+
var tenantIsolationConformance = (subject) => {
|
|
165
|
+
const [first, second] = twoExamples(subject);
|
|
166
|
+
return [
|
|
167
|
+
{
|
|
168
|
+
name: "a tenant finds only its own documents",
|
|
169
|
+
run: async () => {
|
|
170
|
+
await subject.reset();
|
|
171
|
+
await subject.inTenant(A, (index) => index.index(first.document));
|
|
172
|
+
await subject.inTenant(B, (index) => index.index(second.document));
|
|
173
|
+
const page = await subject.inTenant(A, (index) => index.query(second.findableBy));
|
|
174
|
+
assertThat(
|
|
175
|
+
!found(page.hits, second.document),
|
|
176
|
+
"a search inside one tenant returned a document another tenant indexed"
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "a read that names no tenant finds nothing",
|
|
182
|
+
run: async () => {
|
|
183
|
+
await subject.reset();
|
|
184
|
+
await subject.inTenant(A, (index) => index.index(first.document));
|
|
185
|
+
const page = await outsideOf(subject)((index) => index.query(first.findableBy));
|
|
186
|
+
assertThat(
|
|
187
|
+
page.hits.length === 0,
|
|
188
|
+
"a search outside every tenant session answered with rows: the wall defaults to visible, so a query that forgot its session reads the whole index"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "a tenant cannot retire another tenant\u2019s document",
|
|
194
|
+
run: async () => {
|
|
195
|
+
await subject.reset();
|
|
196
|
+
await subject.inTenant(A, (index) => index.index(first.document));
|
|
197
|
+
await subject.inTenant(
|
|
198
|
+
B,
|
|
199
|
+
(index) => index.retire({ id: first.document.id, kind: first.document.kind })
|
|
200
|
+
);
|
|
201
|
+
const page = await subject.inTenant(A, (index) => index.query(first.findableBy));
|
|
202
|
+
assertThat(
|
|
203
|
+
found(page.hits, first.document),
|
|
204
|
+
"one tenant deleted another tenant\u2019s document: the isolation policy is on the read and not on the write"
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
];
|
|
209
|
+
};
|
|
210
|
+
var searchConformance = (subject) => [
|
|
211
|
+
...vocabularyConformance(subject),
|
|
212
|
+
...derivedProjectionConformance(subject),
|
|
213
|
+
...tenantIsolationConformance(subject)
|
|
214
|
+
];
|
|
215
|
+
|
|
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
|
+
// src/provider/ddl.ts
|
|
298
|
+
var SEARCH_INDEX_TABLE = "search_index";
|
|
299
|
+
var MIGRATION_PLACEHOLDERS = {
|
|
300
|
+
opsSetting: "{{opsSetting}}",
|
|
301
|
+
tenantSetting: "{{tenantSetting}}"
|
|
302
|
+
};
|
|
303
|
+
var DEFAULT_OPS_VALUE = "on";
|
|
304
|
+
var CUSTOM_SETTING = /^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/;
|
|
305
|
+
var readSettings = (settings) => {
|
|
306
|
+
for (const named of ["opsSetting", "tenantSetting"]) {
|
|
307
|
+
const name = settings[named];
|
|
308
|
+
if (typeof name !== "string" || name === "") {
|
|
309
|
+
throw new SearchRefusal(
|
|
310
|
+
`${named} is required and has no default: the policies and the session that opens them have to agree on the name exactly, and only the consumer knows it`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (!CUSTOM_SETTING.test(name)) {
|
|
314
|
+
throw new SearchRefusal(
|
|
315
|
+
`${named} is "${name}", and Postgres reads a parameter an application defines as \`prefix.name\` \u2014 anything else is rejected as unrecognised the first time a session sets it`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return { ...settings, opsValue: settings.opsValue ?? DEFAULT_OPS_VALUE };
|
|
320
|
+
};
|
|
321
|
+
var quoted = (identifier) => `"${identifier}"`;
|
|
322
|
+
var setting = (name) => `current_setting('${name}', true)`;
|
|
323
|
+
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} (
|
|
327
|
+
"tenant_id" text not null default ${setting(tenantSetting)},
|
|
328
|
+
"kind" text not null,
|
|
329
|
+
"id" text not null,
|
|
330
|
+
"title" text not null,
|
|
331
|
+
"body" text not null,
|
|
332
|
+
"indexed_at" bigint not null,
|
|
333
|
+
"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")
|
|
335
|
+
)`,
|
|
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) => [
|
|
340
|
+
`drop policy if exists ${quoted(name)} on ${table}`,
|
|
341
|
+
`create policy ${quoted(name)} on ${table} as ${as} for all ${both(predicate)}`
|
|
342
|
+
];
|
|
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
|
+
];
|
|
367
|
+
var searchIndexMigrations = (settings) => renderMigrations(readSettings(settings));
|
|
368
|
+
|
|
369
|
+
// src/provider/pg-fts.ts
|
|
370
|
+
import { rowsOf } from "@geonosis/conformance";
|
|
371
|
+
var TOKEN = /[\p{L}\p{N}]+/gu;
|
|
372
|
+
var toTsQuery = (terms) => (terms.match(TOKEN) ?? []).map((token) => `${token.toLowerCase()}:*`).join(" & ");
|
|
373
|
+
var table2 = `"${SEARCH_INDEX_TABLE}"`;
|
|
374
|
+
var COLUMN_OF = { kind: '"kind"' };
|
|
375
|
+
var ORDERINGS = ["relevance", "recent"];
|
|
376
|
+
var pgFtsSearchIndex = ({
|
|
377
|
+
executor,
|
|
378
|
+
nowMs,
|
|
379
|
+
projections
|
|
380
|
+
}) => {
|
|
381
|
+
const kinds = Object.keys(projections);
|
|
382
|
+
const vocabulary = {
|
|
383
|
+
filters: { kind: kinds },
|
|
384
|
+
kinds,
|
|
385
|
+
orderings: [...ORDERINGS]
|
|
386
|
+
};
|
|
387
|
+
const projectionFor = (kind) => {
|
|
388
|
+
const projection = projections[kind];
|
|
389
|
+
if (projection === void 0) {
|
|
390
|
+
throw new SearchRefusal(
|
|
391
|
+
`"${kind}" is not a kind this index has a projection for \u2014 it indexes ${kinds.toSorted().join(", ")}, and a document whose searchable text nothing derives would be stored unfindable`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return projection;
|
|
395
|
+
};
|
|
396
|
+
return {
|
|
397
|
+
index: async (document) => {
|
|
398
|
+
const projection = projectionFor(document.kind);
|
|
399
|
+
await executor.execute({
|
|
400
|
+
params: [
|
|
401
|
+
document.kind,
|
|
402
|
+
document.id,
|
|
403
|
+
projection.title(document.fields),
|
|
404
|
+
projection.body(document.fields),
|
|
405
|
+
nowMs()
|
|
406
|
+
],
|
|
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"`
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
query: async (terms, options) => {
|
|
411
|
+
const read = readSearchQuery(vocabulary, terms, options);
|
|
412
|
+
const params = [];
|
|
413
|
+
const bind = (value) => {
|
|
414
|
+
params.push(value);
|
|
415
|
+
return `$${params.length}`;
|
|
416
|
+
};
|
|
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})`];
|
|
421
|
+
for (const filter of read.filters) {
|
|
422
|
+
const column = COLUMN_OF[filter.name];
|
|
423
|
+
if (column === void 0) {
|
|
424
|
+
throw new SearchRefusal(`"${filter.name}" is a filter no column of this index answers`);
|
|
425
|
+
}
|
|
426
|
+
where.push(`${column} = ${bind(filter.value)}`);
|
|
427
|
+
}
|
|
428
|
+
const recent = read.order === "recent";
|
|
429
|
+
const key = recent ? '"indexed_at"' : rank;
|
|
430
|
+
const after = read.after;
|
|
431
|
+
if (after !== void 0) {
|
|
432
|
+
const head = recent ? `${bind(after.at)}::bigint` : `${bind(after.rank)}::real`;
|
|
433
|
+
where.push(`(${key}, "kind", "id") < (${head}, ${bind(after.kind)}, ${bind(after.id)})`);
|
|
434
|
+
}
|
|
435
|
+
const answer = await executor.execute({
|
|
436
|
+
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)}`
|
|
438
|
+
});
|
|
439
|
+
const rows = rowsOf(answer);
|
|
440
|
+
const hits = rows.slice(0, read.limit).map((row) => ({
|
|
441
|
+
id: row.id,
|
|
442
|
+
indexedAt: Number(row.indexed_at),
|
|
443
|
+
kind: row.kind,
|
|
444
|
+
title: row.title
|
|
445
|
+
}));
|
|
446
|
+
const ended = rows[hits.length - 1];
|
|
447
|
+
return {
|
|
448
|
+
hits,
|
|
449
|
+
...rows.length > read.limit && ended !== void 0 ? {
|
|
450
|
+
next: encodeCursor({
|
|
451
|
+
at: Number(ended.indexed_at),
|
|
452
|
+
id: ended.id,
|
|
453
|
+
kind: ended.kind,
|
|
454
|
+
order: read.order,
|
|
455
|
+
rank: Number(ended.rank),
|
|
456
|
+
terms: read.terms
|
|
457
|
+
})
|
|
458
|
+
} : {}
|
|
459
|
+
};
|
|
460
|
+
},
|
|
461
|
+
retire: async (document) => {
|
|
462
|
+
await executor.execute({
|
|
463
|
+
params: [document.kind, document.id],
|
|
464
|
+
text: `delete from ${table2} where "kind" = $1 and "id" = $2`
|
|
465
|
+
});
|
|
466
|
+
},
|
|
467
|
+
vocabulary
|
|
468
|
+
};
|
|
469
|
+
};
|
|
470
|
+
export {
|
|
471
|
+
DEFAULT_LIMIT,
|
|
472
|
+
DEFAULT_OPS_VALUE,
|
|
473
|
+
MIGRATION_PLACEHOLDERS,
|
|
474
|
+
SEARCH_INDEX_TABLE,
|
|
475
|
+
SearchRefusal,
|
|
476
|
+
derivedProjectionConformance,
|
|
477
|
+
encodeCursor,
|
|
478
|
+
pgFtsSearchIndex,
|
|
479
|
+
readCursor,
|
|
480
|
+
readSearchQuery,
|
|
481
|
+
searchConformance,
|
|
482
|
+
searchIndexMigrations,
|
|
483
|
+
tenantIsolationConformance,
|
|
484
|
+
vocabularyConformance
|
|
485
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
create table if not exists "search_index" (
|
|
2
|
+
"tenant_id" text not null default current_setting('{{tenantSetting}}', true),
|
|
3
|
+
"kind" text not null,
|
|
4
|
+
"id" text not null,
|
|
5
|
+
"title" text not null,
|
|
6
|
+
"body" text not null,
|
|
7
|
+
"indexed_at" bigint not null,
|
|
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")
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
create index if not exists "search_index_search_idx" on "search_index" using gin ("search");
|
|
13
|
+
|
|
14
|
+
create index if not exists "search_index_recent_idx" on "search_index" ("tenant_id", "indexed_at" desc);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
alter table "search_index" enable row level security;
|
|
2
|
+
|
|
3
|
+
alter table "search_index" force row level security;
|
|
4
|
+
|
|
5
|
+
drop policy if exists "tenant_isolation" on "search_index";
|
|
6
|
+
|
|
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)));
|
|
8
|
+
|
|
9
|
+
drop policy if exists "ops_maintenance" on "search_index";
|
|
10
|
+
|
|
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');
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@geonosis/search",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"types": "./dist/index.d.ts",
|
|
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
|
+
"keywords": [
|
|
7
|
+
"search",
|
|
8
|
+
"full-text",
|
|
9
|
+
"postgres",
|
|
10
|
+
"tsvector",
|
|
11
|
+
"keyset",
|
|
12
|
+
"multi-tenant"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/microcompanies/geonosis/tree/main/domains/search",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/microcompanies/geonosis.git",
|
|
18
|
+
"directory": "domains/search"
|
|
19
|
+
},
|
|
20
|
+
"license": "Apache-2.0",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "dist/index.cjs",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"import": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"require": {
|
|
31
|
+
"types": "./dist/index.d.cts",
|
|
32
|
+
"default": "./dist/index.cjs"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"migrations"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@geonosis/conformance": "^0.2.0",
|
|
42
|
+
"@geonosis/db": "^0.2.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/pg": "^8.15.5",
|
|
46
|
+
"embedded-postgres-17": "npm:embedded-postgres@17.10.0-beta.17",
|
|
47
|
+
"embedded-postgres-18": "npm:embedded-postgres@18.4.0-beta.17",
|
|
48
|
+
"pg": "^8.16.3"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=22"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsup",
|
|
58
|
+
"typecheck": "tsc --noEmit"
|
|
59
|
+
}
|
|
60
|
+
}
|