@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.
@@ -0,0 +1,218 @@
1
+ import { Executor } from '@geonosis/db';
2
+
3
+ /** A document's own values, as primitives (D-050): what a projection reads. */
4
+ type DocumentFields = Readonly<Record<string, string>>;
5
+ /**
6
+ * What a caller hands the index — and there is no searchable projection in it.
7
+ *
8
+ * A contract field every implementer must remember to fill is a field one of them will not: two
9
+ * write paths in the source repo each derived the same searchable values by hand, and the one that
10
+ * forgot blanked a column on every document it wrote. The projection is declared once, where the
11
+ * index is built, so no call site is asked for it.
12
+ */
13
+ type IndexedDocument<Kind extends string = string, Fields extends DocumentFields = DocumentFields> = {
14
+ fields: Fields;
15
+ id: string;
16
+ kind: Kind;
17
+ };
18
+ /** Where a document is addressed without being described: what `retire` needs and nothing more. */
19
+ type DocumentAddress<Kind extends string = string> = {
20
+ id: string;
21
+ kind: Kind;
22
+ };
23
+ /** How one kind of document becomes searchable text. Declared once, per kind. */
24
+ type SearchProjection<Fields extends DocumentFields = DocumentFields> = {
25
+ /** Searched, never returned: the body of the thing. */
26
+ body: (fields: Fields) => string;
27
+ /** Searched AND returned: what a hit shows. */
28
+ title: (fields: Fields) => string;
29
+ };
30
+ /** One projection per kind, or it does not compile — a new kind cannot ship unindexed. */
31
+ type SearchProjections<Kind extends string, Fields extends DocumentFields = DocumentFields> = {
32
+ [K in Kind]: SearchProjection<Fields>;
33
+ };
34
+ /**
35
+ * What this index answers, by name AND by value.
36
+ *
37
+ * Publishing filter names without their value vocabularies is how a caller filters for a value
38
+ * nothing emits, gets an empty answer, and reports it as a count (#135).
39
+ */
40
+ type SearchVocabulary<Kind extends string = string> = {
41
+ filters: Readonly<Record<string, readonly string[]>>;
42
+ orderings: readonly string[];
43
+ /** Present so a reader of the vocabulary alone can see what may be indexed. */
44
+ kinds?: readonly Kind[];
45
+ };
46
+ /** Opaque, and minted only by the index that will read it back. */
47
+ type SearchCursor = string;
48
+ type SearchOptions = {
49
+ /** Keyset, never an offset (D-013): the key of the row the last page ended on. */
50
+ after?: SearchCursor;
51
+ filters?: Readonly<Record<string, string>>;
52
+ limit?: number;
53
+ order?: string;
54
+ };
55
+ type SearchHit = {
56
+ id: string;
57
+ /** Epoch milliseconds (D-050). */
58
+ indexedAt: number;
59
+ kind: string;
60
+ title: string;
61
+ };
62
+ type SearchPage = {
63
+ hits: readonly SearchHit[];
64
+ /** Absent when this page is the last one — never an empty string. */
65
+ next?: SearchCursor;
66
+ };
67
+ type SearchIndex<Kind extends string = string> = {
68
+ index: (document: IndexedDocument<Kind>) => Promise<void>;
69
+ query: (terms: string, options?: SearchOptions) => Promise<SearchPage>;
70
+ retire: (document: DocumentAddress<Kind>) => Promise<void>;
71
+ vocabulary: SearchVocabulary<Kind>;
72
+ };
73
+ /** A question this index will not answer, as opposed to one it answers with nothing. */
74
+ declare class SearchRefusal extends Error {
75
+ constructor(message: string);
76
+ }
77
+ declare const DEFAULT_LIMIT = 50;
78
+ type ReadFilter = {
79
+ name: string;
80
+ value: string;
81
+ };
82
+ type ReadQuery = {
83
+ after?: SearchCursorKey;
84
+ filters: readonly ReadFilter[];
85
+ limit: number;
86
+ order: string;
87
+ terms: string;
88
+ };
89
+ /**
90
+ * The query as this index will run it, or a refusal naming what it could not answer.
91
+ *
92
+ * The ordering falls back to the first the vocabulary declares, so a vocabulary that declares its
93
+ * orderings in preference order needs no second place to say which is the default.
94
+ */
95
+ declare const readSearchQuery: <Kind extends string>(vocabulary: SearchVocabulary<Kind>, terms: string, options?: SearchOptions) => ReadQuery;
96
+ /**
97
+ * The key of the row a page ended on, plus the question it was asked.
98
+ *
99
+ * A relevance rank only means anything under the terms that produced it, so the terms and the
100
+ * ordering travel inside the cursor: a page fetched with the same cursor under a different question
101
+ * is a silently wrong page, which is the shape of the answer nobody checks.
102
+ */
103
+ type SearchCursorKey = {
104
+ /** Epoch milliseconds of the indexed row, so `recent` pages by the same mechanism. */
105
+ at: number;
106
+ id: string;
107
+ kind: string;
108
+ order: string;
109
+ rank: number;
110
+ terms: string;
111
+ };
112
+ declare const encodeCursor: (key: SearchCursorKey) => SearchCursor;
113
+ declare const readCursor: (cursor: SearchCursor, asked: {
114
+ order: string;
115
+ terms: string;
116
+ }) => SearchCursorKey;
117
+
118
+ type SearchConformanceCase = {
119
+ name: string;
120
+ run: () => Promise<void>;
121
+ };
122
+ /**
123
+ * One document this index can take, and two words: one that must find it, one that must not.
124
+ *
125
+ * `notFindableBy` is the whole exam for a derived projection. It is a value the document CARRIES
126
+ * and no projection READS — an index that stored the record it was handed rather than the text its
127
+ * projection derived is found by it, and nothing else can tell the two apart from outside.
128
+ */
129
+ type SearchExample<Kind extends string = string> = {
130
+ document: IndexedDocument<Kind>;
131
+ findableBy: string;
132
+ notFindableBy: string;
133
+ };
134
+ type SearchSubject<Kind extends string = string> = {
135
+ /** At least two, addressed differently: one document proves no wall. */
136
+ examples: readonly SearchExample<Kind>[];
137
+ inTenant: <Result>(tenantId: string, run: (index: SearchIndex<Kind>) => Promise<Result>) => Promise<Result>;
138
+ /** The index reached OUTSIDE every tenant session: what the policies alone let through. */
139
+ outsideAnySession?: <Result>(run: (index: SearchIndex<Kind>) => Promise<Result>) => Promise<Result>;
140
+ /** Empty the index. Whatever role can do that — the exam's own rows are its business. */
141
+ reset: () => Promise<void>;
142
+ };
143
+ /**
144
+ * #135. An index that answers a question it never declared answers it with nothing, and nothing is
145
+ * read as a count. The control case is here on purpose: an index that refused everything would pass
146
+ * the three refusals below without answering anybody.
147
+ */
148
+ declare const vocabularyConformance: <Kind extends string>(subject: SearchSubject<Kind>) => SearchConformanceCase[];
149
+ /** What the write path derived, asked from outside: the only place a handed-in projection shows. */
150
+ declare const derivedProjectionConformance: <Kind extends string>(subject: SearchSubject<Kind>) => SearchConformanceCase[];
151
+ /**
152
+ * The tenant wall under this index's OWN queries.
153
+ *
154
+ * Every read here is the ordinary one. A read the exam qualifies by tenant proves its own where
155
+ * clause; only an unqualified one asks what the policy lets through.
156
+ */
157
+ declare const tenantIsolationConformance: <Kind extends string>(subject: SearchSubject<Kind>) => SearchConformanceCase[];
158
+ /**
159
+ * The whole exam a consumer runs against THEIR provider — and the bar this package's own provider
160
+ * is held to.
161
+ *
162
+ * What it does NOT measure: retrieval semantics. Which words match, how a rank is computed, whether
163
+ * a prefix or a stem or a synonym counts — those live in the DDL and in the dialect, and they
164
+ * differ by design between one index and the next. This exam asks whether a document is findable by
165
+ * what its projection derived and invisible to what it did not, whether a question outside the
166
+ * declared vocabulary is refused rather than answered emptily, and whether the tenant wall holds
167
+ * under the index's own queries. An index that passes may still rank badly.
168
+ */
169
+ declare const searchConformance: <Kind extends string>(subject: SearchSubject<Kind>) => SearchConformanceCase[];
170
+
171
+ /**
172
+ * The session variables the wall is built on. Neither has a default: these are the CONSUMER's
173
+ * vocabulary, and a default here would be one repo's names compiled into everybody's policies —
174
+ * in the one file no lint rule reads (#168).
175
+ */
176
+ type SearchIndexSettings = {
177
+ /** What maintenance sets to reach every tenant. */
178
+ opsSetting: string;
179
+ /** What the ops setting holds while that lever is pulled. */
180
+ opsValue?: string;
181
+ /** What a transaction sets to name the tenant it is open for. */
182
+ tenantSetting: string;
183
+ };
184
+ type SearchMigration = {
185
+ name: string;
186
+ statements: string[];
187
+ };
188
+ declare const SEARCH_INDEX_TABLE = "search_index";
189
+ /** The tokens the shipped `.sql` files carry where a session variable belongs. */
190
+ declare const MIGRATION_PLACEHOLDERS: {
191
+ opsSetting: string;
192
+ tenantSetting: string;
193
+ };
194
+ declare const DEFAULT_OPS_VALUE = "on";
195
+ /**
196
+ * The migrations a consumer applies, in order, with their session variables filled in — for a
197
+ * migrator that templates nothing. The same DDL ships as `.sql` files in this package for one that
198
+ * does.
199
+ */
200
+ declare const searchIndexMigrations: (settings: SearchIndexSettings) => SearchMigration[];
201
+
202
+ type PgFtsOptions<Kind extends string> = {
203
+ /** Already inside whatever session the consumer opened: this provider opens nothing. */
204
+ executor: Executor;
205
+ /** Injected, never ambient (D-050): the composition root passes `Date.now`. */
206
+ nowMs: () => number;
207
+ projections: SearchProjections<Kind>;
208
+ };
209
+ /**
210
+ * Postgres full text search over an injected executor.
211
+ *
212
+ * Nothing here names a tenant. The row's tenant is the column's default, read from the session, and
213
+ * the read carries no predicate at all — the policies in this package's own migrations are what
214
+ * answers that question, so a query that forgot its session sees nothing rather than everything.
215
+ */
216
+ declare const pgFtsSearchIndex: <Kind extends string>({ executor, nowMs, projections, }: PgFtsOptions<Kind>) => SearchIndex<Kind>;
217
+
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 };
@@ -0,0 +1,218 @@
1
+ import { Executor } from '@geonosis/db';
2
+
3
+ /** A document's own values, as primitives (D-050): what a projection reads. */
4
+ type DocumentFields = Readonly<Record<string, string>>;
5
+ /**
6
+ * What a caller hands the index — and there is no searchable projection in it.
7
+ *
8
+ * A contract field every implementer must remember to fill is a field one of them will not: two
9
+ * write paths in the source repo each derived the same searchable values by hand, and the one that
10
+ * forgot blanked a column on every document it wrote. The projection is declared once, where the
11
+ * index is built, so no call site is asked for it.
12
+ */
13
+ type IndexedDocument<Kind extends string = string, Fields extends DocumentFields = DocumentFields> = {
14
+ fields: Fields;
15
+ id: string;
16
+ kind: Kind;
17
+ };
18
+ /** Where a document is addressed without being described: what `retire` needs and nothing more. */
19
+ type DocumentAddress<Kind extends string = string> = {
20
+ id: string;
21
+ kind: Kind;
22
+ };
23
+ /** How one kind of document becomes searchable text. Declared once, per kind. */
24
+ type SearchProjection<Fields extends DocumentFields = DocumentFields> = {
25
+ /** Searched, never returned: the body of the thing. */
26
+ body: (fields: Fields) => string;
27
+ /** Searched AND returned: what a hit shows. */
28
+ title: (fields: Fields) => string;
29
+ };
30
+ /** One projection per kind, or it does not compile — a new kind cannot ship unindexed. */
31
+ type SearchProjections<Kind extends string, Fields extends DocumentFields = DocumentFields> = {
32
+ [K in Kind]: SearchProjection<Fields>;
33
+ };
34
+ /**
35
+ * What this index answers, by name AND by value.
36
+ *
37
+ * Publishing filter names without their value vocabularies is how a caller filters for a value
38
+ * nothing emits, gets an empty answer, and reports it as a count (#135).
39
+ */
40
+ type SearchVocabulary<Kind extends string = string> = {
41
+ filters: Readonly<Record<string, readonly string[]>>;
42
+ orderings: readonly string[];
43
+ /** Present so a reader of the vocabulary alone can see what may be indexed. */
44
+ kinds?: readonly Kind[];
45
+ };
46
+ /** Opaque, and minted only by the index that will read it back. */
47
+ type SearchCursor = string;
48
+ type SearchOptions = {
49
+ /** Keyset, never an offset (D-013): the key of the row the last page ended on. */
50
+ after?: SearchCursor;
51
+ filters?: Readonly<Record<string, string>>;
52
+ limit?: number;
53
+ order?: string;
54
+ };
55
+ type SearchHit = {
56
+ id: string;
57
+ /** Epoch milliseconds (D-050). */
58
+ indexedAt: number;
59
+ kind: string;
60
+ title: string;
61
+ };
62
+ type SearchPage = {
63
+ hits: readonly SearchHit[];
64
+ /** Absent when this page is the last one — never an empty string. */
65
+ next?: SearchCursor;
66
+ };
67
+ type SearchIndex<Kind extends string = string> = {
68
+ index: (document: IndexedDocument<Kind>) => Promise<void>;
69
+ query: (terms: string, options?: SearchOptions) => Promise<SearchPage>;
70
+ retire: (document: DocumentAddress<Kind>) => Promise<void>;
71
+ vocabulary: SearchVocabulary<Kind>;
72
+ };
73
+ /** A question this index will not answer, as opposed to one it answers with nothing. */
74
+ declare class SearchRefusal extends Error {
75
+ constructor(message: string);
76
+ }
77
+ declare const DEFAULT_LIMIT = 50;
78
+ type ReadFilter = {
79
+ name: string;
80
+ value: string;
81
+ };
82
+ type ReadQuery = {
83
+ after?: SearchCursorKey;
84
+ filters: readonly ReadFilter[];
85
+ limit: number;
86
+ order: string;
87
+ terms: string;
88
+ };
89
+ /**
90
+ * The query as this index will run it, or a refusal naming what it could not answer.
91
+ *
92
+ * The ordering falls back to the first the vocabulary declares, so a vocabulary that declares its
93
+ * orderings in preference order needs no second place to say which is the default.
94
+ */
95
+ declare const readSearchQuery: <Kind extends string>(vocabulary: SearchVocabulary<Kind>, terms: string, options?: SearchOptions) => ReadQuery;
96
+ /**
97
+ * The key of the row a page ended on, plus the question it was asked.
98
+ *
99
+ * A relevance rank only means anything under the terms that produced it, so the terms and the
100
+ * ordering travel inside the cursor: a page fetched with the same cursor under a different question
101
+ * is a silently wrong page, which is the shape of the answer nobody checks.
102
+ */
103
+ type SearchCursorKey = {
104
+ /** Epoch milliseconds of the indexed row, so `recent` pages by the same mechanism. */
105
+ at: number;
106
+ id: string;
107
+ kind: string;
108
+ order: string;
109
+ rank: number;
110
+ terms: string;
111
+ };
112
+ declare const encodeCursor: (key: SearchCursorKey) => SearchCursor;
113
+ declare const readCursor: (cursor: SearchCursor, asked: {
114
+ order: string;
115
+ terms: string;
116
+ }) => SearchCursorKey;
117
+
118
+ type SearchConformanceCase = {
119
+ name: string;
120
+ run: () => Promise<void>;
121
+ };
122
+ /**
123
+ * One document this index can take, and two words: one that must find it, one that must not.
124
+ *
125
+ * `notFindableBy` is the whole exam for a derived projection. It is a value the document CARRIES
126
+ * and no projection READS — an index that stored the record it was handed rather than the text its
127
+ * projection derived is found by it, and nothing else can tell the two apart from outside.
128
+ */
129
+ type SearchExample<Kind extends string = string> = {
130
+ document: IndexedDocument<Kind>;
131
+ findableBy: string;
132
+ notFindableBy: string;
133
+ };
134
+ type SearchSubject<Kind extends string = string> = {
135
+ /** At least two, addressed differently: one document proves no wall. */
136
+ examples: readonly SearchExample<Kind>[];
137
+ inTenant: <Result>(tenantId: string, run: (index: SearchIndex<Kind>) => Promise<Result>) => Promise<Result>;
138
+ /** The index reached OUTSIDE every tenant session: what the policies alone let through. */
139
+ outsideAnySession?: <Result>(run: (index: SearchIndex<Kind>) => Promise<Result>) => Promise<Result>;
140
+ /** Empty the index. Whatever role can do that — the exam's own rows are its business. */
141
+ reset: () => Promise<void>;
142
+ };
143
+ /**
144
+ * #135. An index that answers a question it never declared answers it with nothing, and nothing is
145
+ * read as a count. The control case is here on purpose: an index that refused everything would pass
146
+ * the three refusals below without answering anybody.
147
+ */
148
+ declare const vocabularyConformance: <Kind extends string>(subject: SearchSubject<Kind>) => SearchConformanceCase[];
149
+ /** What the write path derived, asked from outside: the only place a handed-in projection shows. */
150
+ declare const derivedProjectionConformance: <Kind extends string>(subject: SearchSubject<Kind>) => SearchConformanceCase[];
151
+ /**
152
+ * The tenant wall under this index's OWN queries.
153
+ *
154
+ * Every read here is the ordinary one. A read the exam qualifies by tenant proves its own where
155
+ * clause; only an unqualified one asks what the policy lets through.
156
+ */
157
+ declare const tenantIsolationConformance: <Kind extends string>(subject: SearchSubject<Kind>) => SearchConformanceCase[];
158
+ /**
159
+ * The whole exam a consumer runs against THEIR provider — and the bar this package's own provider
160
+ * is held to.
161
+ *
162
+ * What it does NOT measure: retrieval semantics. Which words match, how a rank is computed, whether
163
+ * a prefix or a stem or a synonym counts — those live in the DDL and in the dialect, and they
164
+ * differ by design between one index and the next. This exam asks whether a document is findable by
165
+ * what its projection derived and invisible to what it did not, whether a question outside the
166
+ * declared vocabulary is refused rather than answered emptily, and whether the tenant wall holds
167
+ * under the index's own queries. An index that passes may still rank badly.
168
+ */
169
+ declare const searchConformance: <Kind extends string>(subject: SearchSubject<Kind>) => SearchConformanceCase[];
170
+
171
+ /**
172
+ * The session variables the wall is built on. Neither has a default: these are the CONSUMER's
173
+ * vocabulary, and a default here would be one repo's names compiled into everybody's policies —
174
+ * in the one file no lint rule reads (#168).
175
+ */
176
+ type SearchIndexSettings = {
177
+ /** What maintenance sets to reach every tenant. */
178
+ opsSetting: string;
179
+ /** What the ops setting holds while that lever is pulled. */
180
+ opsValue?: string;
181
+ /** What a transaction sets to name the tenant it is open for. */
182
+ tenantSetting: string;
183
+ };
184
+ type SearchMigration = {
185
+ name: string;
186
+ statements: string[];
187
+ };
188
+ declare const SEARCH_INDEX_TABLE = "search_index";
189
+ /** The tokens the shipped `.sql` files carry where a session variable belongs. */
190
+ declare const MIGRATION_PLACEHOLDERS: {
191
+ opsSetting: string;
192
+ tenantSetting: string;
193
+ };
194
+ declare const DEFAULT_OPS_VALUE = "on";
195
+ /**
196
+ * The migrations a consumer applies, in order, with their session variables filled in — for a
197
+ * migrator that templates nothing. The same DDL ships as `.sql` files in this package for one that
198
+ * does.
199
+ */
200
+ declare const searchIndexMigrations: (settings: SearchIndexSettings) => SearchMigration[];
201
+
202
+ type PgFtsOptions<Kind extends string> = {
203
+ /** Already inside whatever session the consumer opened: this provider opens nothing. */
204
+ executor: Executor;
205
+ /** Injected, never ambient (D-050): the composition root passes `Date.now`. */
206
+ nowMs: () => number;
207
+ projections: SearchProjections<Kind>;
208
+ };
209
+ /**
210
+ * Postgres full text search over an injected executor.
211
+ *
212
+ * Nothing here names a tenant. The row's tenant is the column's default, read from the session, and
213
+ * the read carries no predicate at all — the policies in this package's own migrations are what
214
+ * answers that question, so a query that forgot its session sees nothing rather than everything.
215
+ */
216
+ declare const pgFtsSearchIndex: <Kind extends string>({ executor, nowMs, projections, }: PgFtsOptions<Kind>) => SearchIndex<Kind>;
217
+
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 };