@nacre.work/api 0.0.0 → 0.1.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/LICENSE +202 -0
- package/NOTICE +9 -0
- package/dist/adapters.d.ts +326 -15
- package/dist/adapters.d.ts.map +1 -1
- package/dist/adapters.js +992 -52
- package/dist/adapters.js.map +1 -1
- package/dist/audit-export.d.ts +45 -0
- package/dist/audit-export.d.ts.map +1 -0
- package/dist/audit-export.js +155 -0
- package/dist/audit-export.js.map +1 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +17 -7
- package/dist/auth.js.map +1 -1
- package/dist/idempotency.d.ts +62 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +147 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +9 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +50 -5
- package/dist/init.js.map +1 -1
- package/dist/limits.d.ts +80 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +63 -0
- package/dist/limits.js.map +1 -0
- package/dist/login.d.ts +94 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +283 -0
- package/dist/login.js.map +1 -0
- package/dist/main.js +136 -53
- package/dist/main.js.map +1 -1
- package/dist/pagination.d.ts +72 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +110 -0
- package/dist/pagination.js.map +1 -0
- package/dist/rerank.d.ts +99 -0
- package/dist/rerank.d.ts.map +1 -0
- package/dist/rerank.js +138 -0
- package/dist/rerank.js.map +1 -0
- package/dist/server.d.ts +345 -6
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1035 -24
- package/dist/server.js.map +1 -1
- package/dist/service-keys.d.ts +13 -5
- package/dist/service-keys.d.ts.map +1 -1
- package/dist/service-keys.js +54 -31
- package/dist/service-keys.js.map +1 -1
- package/dist/source.d.ts +53 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +109 -0
- package/dist/source.js.map +1 -0
- package/package.json +28 -10
- package/dist/__tests__/admin-surface.test.d.ts +0 -2
- package/dist/__tests__/admin-surface.test.d.ts.map +0 -1
- package/dist/__tests__/admin-surface.test.js +0 -431
- package/dist/__tests__/admin-surface.test.js.map +0 -1
- package/dist/__tests__/init.test.d.ts +0 -2
- package/dist/__tests__/init.test.d.ts.map +0 -1
- package/dist/__tests__/init.test.js +0 -125
- package/dist/__tests__/init.test.js.map +0 -1
- package/dist/__tests__/service-keys.test.d.ts +0 -2
- package/dist/__tests__/service-keys.test.d.ts.map +0 -1
- package/dist/__tests__/service-keys.test.js +0 -204
- package/dist/__tests__/service-keys.test.js.map +0 -1
package/dist/adapters.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { aclTags, buildFilter, effectivePrincipals, loadGrants, loadScopeTree, PostgresGroupGraph, referenceAllows, resolve, VectorStore, vectorName, withOrg, } from '@nacre.work/core';
|
|
1
|
+
import { aclTags, buildFilter, effectivePrincipals, fromStateJson, loadGrants, loadScopeTree, PostgresGroupGraph, referenceAllows, reindexProgress, resolve, toStateJson, VectorStore, vectorName, withOrg, } from '@nacre.work/core';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
+
import { encodeCursor, pageOf } from './pagination.js';
|
|
4
|
+
import { applyRanking } from './rerank.js';
|
|
3
5
|
export class PostgresDocuments {
|
|
4
6
|
pool;
|
|
7
|
+
payload;
|
|
5
8
|
role;
|
|
6
|
-
constructor(pool,
|
|
9
|
+
constructor(pool,
|
|
10
|
+
/**
|
|
11
|
+
* What writes metadata into the payload of a document's points.
|
|
12
|
+
*
|
|
13
|
+
* Narrow on purpose, like the delete path's tombstone port: this endpoint
|
|
14
|
+
* has no business searching, and a port that could would let a later change
|
|
15
|
+
* reach the index without a plan.
|
|
16
|
+
*/
|
|
17
|
+
payload, role) {
|
|
7
18
|
this.pool = pool;
|
|
19
|
+
this.payload = payload;
|
|
8
20
|
this.role = role;
|
|
9
21
|
}
|
|
10
22
|
/**
|
|
@@ -30,7 +42,8 @@ export class PostgresDocuments {
|
|
|
30
42
|
const plan = resolve(await contextFor(client, auth), 'read');
|
|
31
43
|
if (plan.kind === 'none')
|
|
32
44
|
return undefined;
|
|
33
|
-
const { rows } = await client.query(`SELECT d.id, d.title, d.layer_id, l.slug AS layer, d.status, d.chunk_count,
|
|
45
|
+
const { rows } = await client.query(`SELECT d.id, d.title, d.layer_id, l.slug AS layer, d.status, d.chunk_count,
|
|
46
|
+
d.updated_at, d.metadata
|
|
34
47
|
FROM documents d
|
|
35
48
|
JOIN layers l ON l.id = d.layer_id AND l.org_id = d.org_id
|
|
36
49
|
WHERE d.org_id = $1 AND d.id = $2 AND d.deleted_at IS NULL`, [orgId, documentId]);
|
|
@@ -54,53 +67,332 @@ export class PostgresDocuments {
|
|
|
54
67
|
status: row.status,
|
|
55
68
|
chunk_count: Number(row.chunk_count),
|
|
56
69
|
updated_at: row.updated_at.toISOString(),
|
|
70
|
+
// Read back, because a caller who tags a document and cannot see the
|
|
71
|
+
// tag has no way to tell a successful write from a dropped one — and
|
|
72
|
+
// this field was dropped by the handler for as long as it existed.
|
|
73
|
+
metadata: (row.metadata ?? {}),
|
|
57
74
|
};
|
|
58
75
|
}, this.role === undefined ? {} : { role: this.role });
|
|
59
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Replace a document's metadata without re-embedding it.
|
|
79
|
+
*
|
|
80
|
+
* This is the cheap path `docs/api.md` said was not built. Ingest treats a
|
|
81
|
+
* metadata change as a change: the row and the vector payload would otherwise
|
|
82
|
+
* disagree and the document would carry a tag it does not answer to, so a
|
|
83
|
+
* re-tag cost a full re-parse, re-chunk and re-embed. Here the vectors are
|
|
84
|
+
* untouched and only the payload is rewritten — the same `setPayload` the ACL
|
|
85
|
+
* retag sweep uses, for the same reason.
|
|
86
|
+
*
|
|
87
|
+
* `write`, not `read`, and rule 6 means those are not the same set. The
|
|
88
|
+
* answer carries no body at all: a caller who may write to a document and not
|
|
89
|
+
* read it must not learn its title or its layer from a successful PATCH.
|
|
90
|
+
*
|
|
91
|
+
* Row first, then payload, both inside one transaction. A payload write that
|
|
92
|
+
* throws rolls the row back, so the two cannot end up disagreeing in the
|
|
93
|
+
* direction that matters — the row is what the worker rebuilds the payload
|
|
94
|
+
* from on the next index, so a row that ran ahead would be reverted while a
|
|
95
|
+
* payload that ran ahead would not.
|
|
96
|
+
*/
|
|
97
|
+
async updateMetadata(auth, documentId, metadata) {
|
|
98
|
+
if (!/^[0-9a-f-]{36}$/i.test(documentId))
|
|
99
|
+
return false;
|
|
100
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
101
|
+
const { rows: orgs } = await client.query('SELECT vector_collection FROM organizations WHERE id = $1 AND deleted_at IS NULL', [auth.orgId]);
|
|
102
|
+
const collection = orgs[0]?.vector_collection;
|
|
103
|
+
if (collection === undefined)
|
|
104
|
+
return false;
|
|
105
|
+
const plan = resolve(await contextFor(client, auth), 'write');
|
|
106
|
+
if (plan.kind === 'none')
|
|
107
|
+
return false;
|
|
108
|
+
const { rows } = await client.query(`SELECT layer_id FROM documents
|
|
109
|
+
WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL`, [auth.orgId, documentId]);
|
|
110
|
+
const layerId = rows[0]?.layer_id;
|
|
111
|
+
if (layerId === undefined)
|
|
112
|
+
return false;
|
|
113
|
+
if (plan.kind === 'scoped') {
|
|
114
|
+
// deniedDocs first, as on the delete path: a deny beats an allow at
|
|
115
|
+
// any depth, and checking only `layers` would let a document inside
|
|
116
|
+
// an allowed layer be retagged despite an explicit deny on it.
|
|
117
|
+
if (plan.deniedDocs.includes(documentId))
|
|
118
|
+
return false;
|
|
119
|
+
if (!plan.layers.includes(layerId) && !plan.extraDocs.includes(documentId))
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
await client.query(`UPDATE documents SET metadata = $3, updated_at = now()
|
|
123
|
+
WHERE org_id = $1 AND id = $2`, [auth.orgId, documentId, JSON.stringify(metadata)]);
|
|
124
|
+
// `version` is deliberately not bumped. It counts revisions of the
|
|
125
|
+
// document's content, and a tag change is not one — bumping it would
|
|
126
|
+
// make every retag look like an edit to anything watching for one.
|
|
127
|
+
await this.payload.setMetadata(collection, documentId, metadata);
|
|
128
|
+
return true;
|
|
129
|
+
}, this.role === undefined ? {} : { role: this.role });
|
|
130
|
+
}
|
|
60
131
|
}
|
|
61
132
|
export class NacreSearchService {
|
|
62
133
|
deps;
|
|
63
134
|
constructor(deps) {
|
|
64
135
|
this.deps = deps;
|
|
65
136
|
}
|
|
66
|
-
async search(auth, query, topK) {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
137
|
+
async search(auth, query, topK, options = {}) {
|
|
138
|
+
const context = await withOrg(this.deps.pool, auth.orgId, async (client) => {
|
|
139
|
+
// The collection, from the column that owns it. Read in the transaction
|
|
140
|
+
// the plan needs anyway rather than through a cache: a reindex moves
|
|
141
|
+
// this pointer, and a cached one means the API keeps searching the
|
|
142
|
+
// collection the worker has stopped writing to. That failure returns
|
|
143
|
+
// results, just increasingly stale ones, which is the kind nobody
|
|
144
|
+
// notices.
|
|
145
|
+
const { rows: orgs } = await client.query('SELECT vector_collection FROM organizations WHERE id = $1 AND deleted_at IS NULL', [auth.orgId]);
|
|
146
|
+
const collection = orgs[0]?.vector_collection;
|
|
147
|
+
if (collection === undefined)
|
|
148
|
+
return undefined;
|
|
74
149
|
const graph = await PostgresGroupGraph.load(client, auth.orgId);
|
|
75
150
|
const principals = effectivePrincipals(auth.principal, graph);
|
|
76
151
|
const grants = await loadGrants(client, auth.orgId, principals);
|
|
77
152
|
const tree = await loadScopeTree(client, auth.orgId, grants.filter((g) => g.scope.type === 'document').map((g) => g.scope.id));
|
|
78
|
-
|
|
153
|
+
const plan = resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'read');
|
|
154
|
+
// Every live layer with the model it is on. Small — an installation
|
|
155
|
+
// runs tens of layers per organization, not thousands — and it is what
|
|
156
|
+
// decides how many branches the query has.
|
|
157
|
+
const { rows: layers } = await client.query(`SELECT l.id, l.vector_name, p.id AS provider_id, p.endpoint, p.model, p.dimensions
|
|
158
|
+
FROM layers l
|
|
159
|
+
JOIN embedding_providers p ON p.id = l.provider_id
|
|
160
|
+
WHERE l.org_id = $1 AND l.deleted_at IS NULL`, [auth.orgId]);
|
|
161
|
+
return { collection, plan, layers };
|
|
79
162
|
}, this.deps.role === undefined ? {} : { role: this.deps.role });
|
|
163
|
+
if (context === undefined) {
|
|
164
|
+
// The token names an organization that is not there. Rule I3: a
|
|
165
|
+
// permission that cannot be evaluated denies.
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
const { collection, plan } = context;
|
|
80
169
|
// No access at all. Returning early rather than querying is not an
|
|
81
170
|
// optimization — buildFilter refuses this plan, and it refuses it because
|
|
82
171
|
// there is no filter that means "nothing" without meaning "everything" by
|
|
83
172
|
// one mistake.
|
|
84
173
|
if (plan.kind === 'none')
|
|
85
174
|
return [];
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
175
|
+
// The caller's own restriction, resolved before the query.
|
|
176
|
+
//
|
|
177
|
+
// `layers` was declared in openapi.yaml and in the MCP tool schema from the
|
|
178
|
+
// beginning and read by nothing, so a client scoping a search to one layer
|
|
179
|
+
// silently searched all of them. For a product whose selling point is that
|
|
180
|
+
// a search returns only what you may see, a scoping parameter that does
|
|
181
|
+
// nothing is the worst kind of no-op: the caller believes they narrowed it.
|
|
182
|
+
let narrow;
|
|
183
|
+
if (options.filters !== undefined && Object.keys(options.filters).length > 0) {
|
|
184
|
+
// Metadata alone is a valid narrowing — a caller may filter by source
|
|
185
|
+
// without naming a layer. `buildFilter` turns each entry into a `must`
|
|
186
|
+
// beside the permission constraint; there is no path from here to a
|
|
187
|
+
// filter the caller assembled.
|
|
188
|
+
narrow = { metadata: options.filters };
|
|
189
|
+
}
|
|
190
|
+
if (options.layers !== undefined && options.layers.length > 0) {
|
|
191
|
+
const resolved = await this.layerIds(auth.orgId, options.layers);
|
|
192
|
+
// Intersected with what the plan actually reaches, before deciding
|
|
193
|
+
// whether to query at all.
|
|
194
|
+
//
|
|
195
|
+
// The filter would already return nothing for a layer this caller cannot
|
|
196
|
+
// read — `must` on layer_id against a `should` that names other layers
|
|
197
|
+
// matches no point. Doing it here as well closes a narrower hole: without
|
|
198
|
+
// it, an unreadable-but-existing layer runs the query and an absent one
|
|
199
|
+
// does not, so anything that makes the query path *fail* — the vector
|
|
200
|
+
// store being down — separates the two. That is invariant I4 leaking
|
|
201
|
+
// through a dependency outage, and it costs six lines to remove. It also
|
|
202
|
+
// saves a round trip in the ordinary case.
|
|
203
|
+
//
|
|
204
|
+
// Not intersected when the plan carries `extraDocs`: those are documents
|
|
205
|
+
// granted individually and they can sit in any layer, including one the
|
|
206
|
+
// caller reaches no other way. Dropping such a layer here would answer
|
|
207
|
+
// empty where the correct answer is "the documents you were granted".
|
|
208
|
+
const permitted = plan.kind === 'scoped' && plan.extraDocs.length === 0
|
|
209
|
+
? resolved.filter((id) => plan.layers.includes(id))
|
|
210
|
+
: resolved;
|
|
211
|
+
// Nothing the caller named is reachable. That covers a slug that does not
|
|
212
|
+
// exist and one that does but sits outside their grants — and it has to,
|
|
213
|
+
// because invariant I4 makes those the same answer. Empty results, no
|
|
214
|
+
// error naming the layer, and no query.
|
|
215
|
+
if (permitted.length === 0)
|
|
216
|
+
return [];
|
|
217
|
+
narrow = { ...narrow, layers: permitted };
|
|
218
|
+
}
|
|
219
|
+
// One branch per model actually in scope.
|
|
220
|
+
//
|
|
221
|
+
// Almost always exactly one: an organization runs a single embedding model
|
|
222
|
+
// and every layer is on it. Two while a reindex is part-way through, and
|
|
223
|
+
// two for as long as an operator keeps layers on different providers.
|
|
224
|
+
// Zero means the organization has no layers, or none the caller can reach,
|
|
225
|
+
// and there is nothing to ask the index.
|
|
226
|
+
const groups = this.groupsInScope(context.layers, plan, narrow);
|
|
227
|
+
if (groups.length === 0)
|
|
228
|
+
return [];
|
|
229
|
+
const branches = await Promise.all(groups.map(async (group) => {
|
|
230
|
+
const [vector] = await this.deps.embedderFor(group.provider).embed([query]);
|
|
231
|
+
if (vector === undefined) {
|
|
232
|
+
throw new Error(`the embedder for ${group.provider.model} returned no vector for the query`);
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
kind: 'dense',
|
|
236
|
+
using: group.vectorName,
|
|
237
|
+
vector,
|
|
238
|
+
// Confined to the layers on this model. A reindexed layer keeps its
|
|
239
|
+
// old vector until the collection is rebuilt without it, so without
|
|
240
|
+
// this it would match the other model's branch too and fusion would
|
|
241
|
+
// rank it above everything for no reason but having been migrated.
|
|
242
|
+
//
|
|
243
|
+
// Only when there is more than one group: with a single model this
|
|
244
|
+
// clause is every layer in the organization, which is a longer filter
|
|
245
|
+
// that removes nothing.
|
|
246
|
+
...(groups.length > 1 ? { onlyLayers: group.layerIds } : {}),
|
|
247
|
+
};
|
|
248
|
+
}));
|
|
249
|
+
// Off unless the deployment configured a reranker and the caller left it on.
|
|
250
|
+
const reranking = this.deps.reranker !== undefined && options.rerank !== false;
|
|
89
251
|
const hits = await this.deps.vectors.search({
|
|
90
252
|
orgId: auth.orgId,
|
|
91
|
-
|
|
253
|
+
collection,
|
|
92
254
|
plan,
|
|
93
|
-
branches
|
|
94
|
-
//
|
|
95
|
-
// costs more.
|
|
96
|
-
|
|
255
|
+
branches,
|
|
256
|
+
// Without a reranker this is passed through uncorrected: asking for more
|
|
257
|
+
// and trimming would be a post-filter that also costs more.
|
|
258
|
+
//
|
|
259
|
+
// With one it is widened, and that is not the same thing. The trim a
|
|
260
|
+
// reranker performs is on relevance over a set the index has already
|
|
261
|
+
// filtered by permission, so it changes *which* permitted results come
|
|
262
|
+
// back and never how many. See rerank.ts — the distinction is the whole
|
|
263
|
+
// argument for this being allowed at all.
|
|
264
|
+
topK: reranking ? Math.max(topK, this.deps.rerankCandidates ?? 50) : topK,
|
|
265
|
+
// A `must` on layer_id inside the traversal, never a trim afterwards.
|
|
266
|
+
// Narrowing a search is still a pre-filter: `top_k` comes back full from
|
|
267
|
+
// the smaller set rather than being cut down from the larger one.
|
|
268
|
+
...(narrow === undefined ? {} : { narrow }),
|
|
97
269
|
});
|
|
98
270
|
// Invariant I1, checked again on the way out. Raises rather than filtering:
|
|
99
271
|
// silently dropping the row would hide the bug that produced it.
|
|
100
272
|
const checked = VectorStore.assertTenant(auth.orgId, hits);
|
|
101
273
|
if (checked.length === 0)
|
|
102
274
|
return [];
|
|
103
|
-
|
|
275
|
+
const candidates = await this.hydrate(auth.orgId, checked);
|
|
276
|
+
const ranked = reranking ? await this.rerank(query, candidates, topK) : candidates;
|
|
277
|
+
// Last, because reranking scores the query against the text. Dropping it
|
|
278
|
+
// earlier would rerank against nothing.
|
|
279
|
+
return options.includeContent === false ? ranked.map((hit) => ({ ...hit, text: '' })) : ranked;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The organization's layers, grouped by the model they are indexed with.
|
|
283
|
+
*
|
|
284
|
+
* Restricted first to what this search can actually reach — the permission
|
|
285
|
+
* plan, then the caller's own `layers` narrowing — so a query never embeds
|
|
286
|
+
* against a provider whose layers contribute nothing. That is not only a
|
|
287
|
+
* saving: an embedding endpoint that is down would otherwise fail a search
|
|
288
|
+
* over layers that have no connection to it.
|
|
289
|
+
*
|
|
290
|
+
* A plan carrying `extraDocs` keeps every group. Documents granted
|
|
291
|
+
* individually can sit in any layer, including one the caller reaches no
|
|
292
|
+
* other way, so dropping a group here would answer empty where the correct
|
|
293
|
+
* answer is "the documents you were granted".
|
|
294
|
+
*/
|
|
295
|
+
groupsInScope(layers, plan, narrow) {
|
|
296
|
+
const reachable = layers.filter((l) => {
|
|
297
|
+
// Only the layer half narrows the branch set. A metadata restriction says
|
|
298
|
+
// nothing about which models are in scope: it removes points inside the
|
|
299
|
+
// query rather than removing layers from it.
|
|
300
|
+
if (narrow?.layers !== undefined && !narrow.layers.includes(l.id))
|
|
301
|
+
return false;
|
|
302
|
+
if (plan.kind === 'all')
|
|
303
|
+
return true;
|
|
304
|
+
if (plan.extraDocs.length > 0)
|
|
305
|
+
return true;
|
|
306
|
+
return plan.layers.includes(l.id);
|
|
307
|
+
});
|
|
308
|
+
const byVector = new Map();
|
|
309
|
+
for (const layer of reachable) {
|
|
310
|
+
// `vector_name` and `provider_id` have to agree, because the first says
|
|
311
|
+
// which slot to search and the second says which model to embed the query
|
|
312
|
+
// with. They disagreed once already — a reindex switched the vector and
|
|
313
|
+
// left the provider behind — and the symptom was Qdrant refusing the
|
|
314
|
+
// whole query on a dimension mismatch, which takes every *other* layer in
|
|
315
|
+
// the organization down with it. Named here rather than left to arrive as
|
|
316
|
+
// `Bad Request`, and raised rather than skipped: dropping the layer from
|
|
317
|
+
// the branch set would answer a search with a silently smaller corpus.
|
|
318
|
+
const expected = vectorName(layer.model, layer.dimensions);
|
|
319
|
+
if (expected !== layer.vector_name) {
|
|
320
|
+
throw new Error(`layer ${layer.id} names vector ${layer.vector_name} but its provider is ` +
|
|
321
|
+
`${layer.model}/${layer.dimensions}, which is ${expected}. A reindex left the two ` +
|
|
322
|
+
'out of step; the layer cannot be searched until they agree.');
|
|
323
|
+
}
|
|
324
|
+
const group = byVector.get(layer.vector_name) ?? {
|
|
325
|
+
provider: {
|
|
326
|
+
id: layer.provider_id,
|
|
327
|
+
endpoint: layer.endpoint,
|
|
328
|
+
model: layer.model,
|
|
329
|
+
dimensions: layer.dimensions,
|
|
330
|
+
},
|
|
331
|
+
layerIds: [],
|
|
332
|
+
};
|
|
333
|
+
group.layerIds.push(layer.id);
|
|
334
|
+
byVector.set(layer.vector_name, group);
|
|
335
|
+
}
|
|
336
|
+
return [...byVector].map(([vectorName, group]) => ({
|
|
337
|
+
vectorName,
|
|
338
|
+
provider: group.provider,
|
|
339
|
+
layerIds: group.layerIds,
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Layer slugs to ids, within one organization.
|
|
344
|
+
*
|
|
345
|
+
* Scoped through `withOrg` like every other tenant read, so a slug belonging
|
|
346
|
+
* to another organization resolves to nothing rather than to their layer. The
|
|
347
|
+
* result is intersected with what the caller may reach by the filter itself —
|
|
348
|
+
* the ids become a `must` and the permission constraint stays a `should`, so
|
|
349
|
+
* a layer this caller cannot read contributes no results even when it
|
|
350
|
+
* resolves here.
|
|
351
|
+
*
|
|
352
|
+
* Unknown slugs are dropped rather than reported. Answering "no such layer"
|
|
353
|
+
* would separate "does not exist" from "you cannot see it", which is exactly
|
|
354
|
+
* the distinction invariant I4 exists to prevent.
|
|
355
|
+
*/
|
|
356
|
+
async layerIds(orgId, slugs) {
|
|
357
|
+
// Bounded, because this is an unauthenticated-shaped input on an
|
|
358
|
+
// authenticated path: an array of ten thousand slugs is one query with ten
|
|
359
|
+
// thousand parameters otherwise.
|
|
360
|
+
const wanted = [...new Set(slugs.map((s) => s.trim().toLowerCase()).filter((s) => s !== ''))].slice(0, 64);
|
|
361
|
+
if (wanted.length === 0)
|
|
362
|
+
return [];
|
|
363
|
+
return withOrg(this.deps.pool, orgId, async (client) => {
|
|
364
|
+
const { rows } = await client.query('SELECT id FROM layers WHERE org_id = $1 AND slug = ANY($2::text[])', [orgId, wanted]);
|
|
365
|
+
return rows.map((r) => r.id);
|
|
366
|
+
}, this.deps.role === undefined ? {} : { role: this.deps.role });
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Reorder by cross-encoder score, or answer in fusion order and say so.
|
|
370
|
+
*
|
|
371
|
+
* The text comes from the hydration that had to happen anyway. A reranker
|
|
372
|
+
* scores query against text, and the vector store holds no text — so the
|
|
373
|
+
* order here is search, hydrate the candidates, rerank, cut. Hydrating fifty
|
|
374
|
+
* rows rather than ten is one query with a wider `ANY($2)`, which is the cost
|
|
375
|
+
* of the quality this buys.
|
|
376
|
+
*
|
|
377
|
+
* Failure answers with what fusion produced rather than raising. Reranking
|
|
378
|
+
* decides ordering over candidates that were all permitted before it saw
|
|
379
|
+
* them, so a reranker being down is a quality degradation and not a
|
|
380
|
+
* permissions one — and making the product's central operation depend on an
|
|
381
|
+
* optional model server would be the worse trade. It is reported, never
|
|
382
|
+
* silent.
|
|
383
|
+
*/
|
|
384
|
+
async rerank(query, candidates, topK) {
|
|
385
|
+
const reranker = this.deps.reranker;
|
|
386
|
+
if (reranker === undefined)
|
|
387
|
+
return candidates.slice(0, topK);
|
|
388
|
+
try {
|
|
389
|
+
const scores = await reranker.rank(query, candidates.map((c) => c.text));
|
|
390
|
+
return applyRanking(candidates, scores, topK);
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
this.deps.onRerankFailed?.(error);
|
|
394
|
+
return candidates.slice(0, topK);
|
|
395
|
+
}
|
|
104
396
|
}
|
|
105
397
|
/**
|
|
106
398
|
* Turn hits into results a caller can read.
|
|
@@ -182,14 +474,22 @@ export class PostgresAudit {
|
|
|
182
474
|
async write(event) {
|
|
183
475
|
const [actorType, actorId] = event.actor.split(':');
|
|
184
476
|
await withOrg(this.pool, event.orgId, async (client) => {
|
|
185
|
-
await client.query(
|
|
477
|
+
await client.query(
|
|
478
|
+
// `surface` and `target` were literals in this statement — 'api' and
|
|
479
|
+
// an empty object — so every MCP call was logged as REST and the
|
|
480
|
+
// `gin (target)` index built for this indexed nothing. The columns
|
|
481
|
+
// and the schema were right from the first migration; only the write
|
|
482
|
+
// was not.
|
|
483
|
+
`INSERT INTO audit_events
|
|
186
484
|
(org_id, actor_type, actor_id, actor_label, action, surface, target, result, detail, request_id)
|
|
187
|
-
VALUES ($1,$2,$3,$4,$5
|
|
485
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10)`, [
|
|
188
486
|
event.orgId,
|
|
189
487
|
actorType ?? 'unknown',
|
|
190
488
|
/^[0-9a-f-]{36}$/i.test(actorId ?? '') ? actorId : null,
|
|
191
489
|
event.actor,
|
|
192
490
|
event.action,
|
|
491
|
+
event.surface ?? 'api',
|
|
492
|
+
JSON.stringify(event.target ?? {}),
|
|
193
493
|
event.result,
|
|
194
494
|
JSON.stringify(event.detail),
|
|
195
495
|
event.requestId,
|
|
@@ -208,18 +508,46 @@ export class HttpEmbedder {
|
|
|
208
508
|
endpoint;
|
|
209
509
|
model;
|
|
210
510
|
dimensions;
|
|
211
|
-
|
|
511
|
+
timeoutMs;
|
|
512
|
+
constructor(endpoint, model, dimensions,
|
|
513
|
+
/** One query, one vector, and a caller waiting on it. */
|
|
514
|
+
timeoutMs = 15_000) {
|
|
212
515
|
this.endpoint = endpoint;
|
|
213
516
|
this.model = model;
|
|
214
517
|
this.dimensions = dimensions;
|
|
518
|
+
this.timeoutMs = timeoutMs;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* One embedder per provider, built once and reused.
|
|
522
|
+
*
|
|
523
|
+
* Keyed on the provider's id, so an operator who edits an endpoint has to
|
|
524
|
+
* restart — which is true of every other piece of configuration here, and the
|
|
525
|
+
* alternative is a cache that has to be invalidated from a table nothing
|
|
526
|
+
* watches.
|
|
527
|
+
*/
|
|
528
|
+
static pool(timeoutMs) {
|
|
529
|
+
const embedders = new Map();
|
|
530
|
+
return (provider) => {
|
|
531
|
+
const cached = embedders.get(provider.id);
|
|
532
|
+
if (cached !== undefined)
|
|
533
|
+
return cached;
|
|
534
|
+
const embedder = new HttpEmbedder(provider.endpoint, provider.model, provider.dimensions, ...(timeoutMs === undefined ? [] : [timeoutMs]));
|
|
535
|
+
embedders.set(provider.id, embedder);
|
|
536
|
+
return embedder;
|
|
537
|
+
};
|
|
215
538
|
}
|
|
216
539
|
async embed(texts) {
|
|
217
540
|
if (texts.length === 0)
|
|
218
541
|
return [];
|
|
542
|
+
// On the search path, so much tighter than the worker's: a caller is
|
|
543
|
+
// waiting. Without any bound a wedged embedder held every search open for
|
|
544
|
+
// undici's 300 s default, which exhausts the connection pool long before
|
|
545
|
+
// anyone sees an error.
|
|
219
546
|
const response = await fetch(new URL('/embeddings', this.endpoint), {
|
|
220
547
|
method: 'POST',
|
|
221
548
|
headers: { 'content-type': 'application/json' },
|
|
222
549
|
body: JSON.stringify({ model: this.model, input: texts }),
|
|
550
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
223
551
|
});
|
|
224
552
|
if (!response.ok) {
|
|
225
553
|
throw new Error(`the embedding endpoint answered ${response.status}`);
|
|
@@ -281,8 +609,8 @@ export class NacreIngest {
|
|
|
281
609
|
const sourceType = request.content !== undefined ? 'inline' : 'url';
|
|
282
610
|
const hash = createHash('sha256').update(source, 'utf8').digest('hex');
|
|
283
611
|
const { rows } = await client.query(`INSERT INTO documents
|
|
284
|
-
(org_id, layer_id, external_id, source_type, source_ref, title, content_hash, status)
|
|
285
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,'pending')
|
|
612
|
+
(org_id, layer_id, external_id, source_type, source_ref, title, content_hash, metadata, status)
|
|
613
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending')
|
|
286
614
|
ON CONFLICT (layer_id, external_id) DO UPDATE SET
|
|
287
615
|
-- Only touch the row when the content actually changed. A repeat
|
|
288
616
|
-- with the same bytes must not re-queue work: every client that
|
|
@@ -291,7 +619,19 @@ export class NacreIngest {
|
|
|
291
619
|
THEN EXCLUDED.source_ref ELSE documents.source_ref END,
|
|
292
620
|
title = COALESCE(EXCLUDED.title, documents.title),
|
|
293
621
|
content_hash = EXCLUDED.content_hash,
|
|
622
|
+
metadata = EXCLUDED.metadata,
|
|
623
|
+
-- Metadata counts as a change, because it is written into the
|
|
624
|
+
-- vector payload of every point and a filter reads it from there.
|
|
625
|
+
-- Updating the row alone would leave the two disagreeing: the
|
|
626
|
+
-- document would carry a tag it does not answer to, which is the
|
|
627
|
+
-- silent shape of failure this repository keeps removing.
|
|
628
|
+
--
|
|
629
|
+
-- The cost is real — this re-embeds a document whose bytes did not
|
|
630
|
+
-- change — and the cheaper path is a payload-only write like the
|
|
631
|
+
-- ACL retag sweep does. Not built; docs/api.md says so rather than
|
|
632
|
+
-- letting an operator discover it from a bill.
|
|
294
633
|
status = CASE WHEN documents.content_hash IS DISTINCT FROM EXCLUDED.content_hash
|
|
634
|
+
OR documents.metadata IS DISTINCT FROM EXCLUDED.metadata
|
|
295
635
|
THEN 'pending' ELSE documents.status END,
|
|
296
636
|
deleted_at = NULL,
|
|
297
637
|
updated_at = now()
|
|
@@ -303,6 +643,7 @@ export class NacreIngest {
|
|
|
303
643
|
source,
|
|
304
644
|
request.title ?? null,
|
|
305
645
|
`sha256:${hash}`,
|
|
646
|
+
JSON.stringify(request.metadata),
|
|
306
647
|
]);
|
|
307
648
|
const row = rows[0];
|
|
308
649
|
if (row === undefined)
|
|
@@ -317,13 +658,15 @@ export class NacreIngest {
|
|
|
317
658
|
async remove(auth, documentId) {
|
|
318
659
|
if (!/^[0-9a-f-]{36}$/i.test(documentId))
|
|
319
660
|
return false;
|
|
320
|
-
const slug = await this.deps.orgSlug(auth.orgId);
|
|
321
|
-
// The token names an organization that is not there. Refusing rather than
|
|
322
|
-
// deleting only in Postgres: a tombstone we cannot mirror into the index is
|
|
323
|
-
// a document that stays searchable while the API reports it gone.
|
|
324
|
-
if (slug === undefined)
|
|
325
|
-
return false;
|
|
326
661
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
662
|
+
// The collection, from the column that owns it, inside the same
|
|
663
|
+
// transaction as the permission check. Refusing rather than deleting
|
|
664
|
+
// only in Postgres: a tombstone we cannot mirror into the index is a
|
|
665
|
+
// document that stays searchable while the API reports it gone.
|
|
666
|
+
const { rows: orgs } = await client.query('SELECT vector_collection FROM organizations WHERE id = $1 AND deleted_at IS NULL', [auth.orgId]);
|
|
667
|
+
const collection = orgs[0]?.vector_collection;
|
|
668
|
+
if (collection === undefined)
|
|
669
|
+
return false;
|
|
327
670
|
const graph = await PostgresGroupGraph.load(client, auth.orgId);
|
|
328
671
|
const principals = effectivePrincipals(auth.principal, graph);
|
|
329
672
|
const grants = await loadGrants(client, auth.orgId, principals);
|
|
@@ -363,7 +706,7 @@ export class NacreIngest {
|
|
|
363
706
|
// which purges rather than repairs. Index-first fails the other way: the
|
|
364
707
|
// points are marked, the row is not, the caller sees an error and
|
|
365
708
|
// retries, and in the meantime the document is already invisible.
|
|
366
|
-
await this.deps.tombstone.tombstone(
|
|
709
|
+
await this.deps.tombstone.tombstone(collection, documentId);
|
|
367
710
|
// A tombstone, not a delete, on this side too. Physical removal of the
|
|
368
711
|
// points is the collector's job, and `deleted_at` is what puts the
|
|
369
712
|
// document in its queue.
|
|
@@ -440,9 +783,23 @@ async function contextFor(client, auth) {
|
|
|
440
783
|
}
|
|
441
784
|
export class PostgresLayers {
|
|
442
785
|
pool;
|
|
786
|
+
vectors;
|
|
443
787
|
role;
|
|
444
|
-
constructor(pool,
|
|
788
|
+
constructor(pool,
|
|
789
|
+
/**
|
|
790
|
+
* Only to ask which named vectors the organization's collection has.
|
|
791
|
+
*
|
|
792
|
+
* A layer is created against a provider, and the provider decides which
|
|
793
|
+
* named vector the layer's points go into. Qdrant fixes that set when the
|
|
794
|
+
* collection is created and has no way to extend it, so a layer on a
|
|
795
|
+
* provider the collection has no slot for is a layer whose every document
|
|
796
|
+
* fails in the worker with `Not existing vector name` — after the API has
|
|
797
|
+
* answered 202 and the row says `pending`. Checking here is what turns that
|
|
798
|
+
* into a collection rebuild instead.
|
|
799
|
+
*/
|
|
800
|
+
vectors, role) {
|
|
445
801
|
this.pool = pool;
|
|
802
|
+
this.vectors = vectors;
|
|
446
803
|
this.role = role;
|
|
447
804
|
}
|
|
448
805
|
get scope() {
|
|
@@ -456,11 +813,11 @@ export class PostgresLayers {
|
|
|
456
813
|
* break invariant I2 — but writing it the other way keeps one habit for both
|
|
457
814
|
* and removes the question of which endpoints are allowed to be sloppy.
|
|
458
815
|
*/
|
|
459
|
-
async list(auth) {
|
|
816
|
+
async list(auth, page) {
|
|
460
817
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
461
818
|
const plan = resolve(await contextFor(client, auth), 'read');
|
|
462
819
|
if (plan.kind === 'none')
|
|
463
|
-
return [];
|
|
820
|
+
return { items: [], nextCursor: null };
|
|
464
821
|
// The count comes from the same statement. It is what a catalog is
|
|
465
822
|
// for — an agent choosing where to search, or a person deciding
|
|
466
823
|
// whether a layer is worth opening — and the MCP catalog has always
|
|
@@ -472,25 +829,40 @@ export class PostgresLayers {
|
|
|
472
829
|
// grants exist — a commercial module — this becomes a number that
|
|
473
830
|
// discloses more than the caller can reach, and it has to be recomputed
|
|
474
831
|
// per caller or dropped.
|
|
475
|
-
const projection = `l.id, l.slug, l.name, l.workspace_id, l.description,
|
|
832
|
+
const projection = `l.id, l.slug, l.name, l.workspace_id, l.description, l.created_at,
|
|
476
833
|
(SELECT count(*) FROM documents d
|
|
477
834
|
WHERE d.layer_id = l.id AND d.deleted_at IS NULL) AS document_count`;
|
|
835
|
+
// Ordered by (created_at, id) rather than by slug, because that is the
|
|
836
|
+
// cursor's sort key and a page has to be stable under inserts. Sorting
|
|
837
|
+
// by a mutable column would move rows between pages when one is renamed.
|
|
838
|
+
const order = 'ORDER BY l.created_at, l.id';
|
|
839
|
+
const after = page?.after;
|
|
840
|
+
const seek = after === undefined ? '' : ' AND (l.created_at, l.id) > ($3::timestamptz, $4::uuid)';
|
|
841
|
+
const cap = page === undefined ? '' : ` LIMIT ${page.limit}`;
|
|
478
842
|
const { rows } = plan.kind === 'all'
|
|
479
843
|
? await client.query(`SELECT ${projection} FROM layers l
|
|
480
|
-
WHERE l.org_id = $1 AND l.deleted_at IS NULL
|
|
844
|
+
WHERE l.org_id = $1 AND l.deleted_at IS NULL${seek.replace('$3', '$2').replace('$4', '$3')}
|
|
845
|
+
${order}${cap}`, after === undefined ? [auth.orgId] : [auth.orgId, after.createdAt, after.id])
|
|
481
846
|
: await client.query(`SELECT ${projection} FROM layers l
|
|
482
|
-
WHERE l.org_id = $1 AND l.deleted_at IS NULL AND l.id = ANY($2::uuid[])
|
|
483
|
-
|
|
484
|
-
|
|
847
|
+
WHERE l.org_id = $1 AND l.deleted_at IS NULL AND l.id = ANY($2::uuid[])${seek}
|
|
848
|
+
${order}${cap}`, after === undefined
|
|
849
|
+
? [auth.orgId, [...plan.layers]]
|
|
850
|
+
: [auth.orgId, [...plan.layers], after.createdAt, after.id]);
|
|
851
|
+
const layers = rows.map((r) => ({
|
|
485
852
|
id: r.id,
|
|
486
853
|
slug: r.slug,
|
|
487
854
|
name: r.name,
|
|
488
855
|
workspaceId: r.workspace_id,
|
|
489
856
|
description: r.description ?? '',
|
|
490
857
|
documentCount: Number(r.document_count),
|
|
858
|
+
createdAt: r.created_at.toISOString(),
|
|
491
859
|
}));
|
|
860
|
+
return pageOf(layers, page, (l) => ({ createdAt: l.createdAt, id: l.id }));
|
|
492
861
|
}, this.scope);
|
|
493
862
|
}
|
|
863
|
+
async update(auth, layerId, input) {
|
|
864
|
+
return updateLayer(this.pool, auth, layerId, input, this.role);
|
|
865
|
+
}
|
|
494
866
|
async create(auth, input) {
|
|
495
867
|
if (!/^[0-9a-f-]{36}$/i.test(input.workspaceId))
|
|
496
868
|
return { kind: 'denied' };
|
|
@@ -509,9 +881,35 @@ export class PostgresLayers {
|
|
|
509
881
|
const { rows: workspaces } = await client.query(`SELECT id FROM workspaces WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL`, [auth.orgId, input.workspaceId]);
|
|
510
882
|
if (workspaces[0] === undefined)
|
|
511
883
|
return { kind: 'denied' };
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
884
|
+
// The caller's choice, or the organization's if there is no ambiguity.
|
|
885
|
+
//
|
|
886
|
+
// `ORDER BY org_id NULLS LAST LIMIT 1` was the whole of this, which is
|
|
887
|
+
// fine while an organization has one provider and arbitrary the moment
|
|
888
|
+
// it has two — two rows with the same non-null `org_id` come back in
|
|
889
|
+
// whatever order the database felt like, so the model a layer was
|
|
890
|
+
// created against was a coin toss with no way for the caller to call
|
|
891
|
+
// it. Now they can, and where they do not, an ambiguous choice is
|
|
892
|
+
// refused rather than guessed.
|
|
893
|
+
const { rows: providers } = await client.query(`SELECT id, model, dimensions, (org_id IS NOT NULL) AS own
|
|
894
|
+
FROM embedding_providers
|
|
895
|
+
WHERE (org_id = $1 OR org_id IS NULL)
|
|
896
|
+
AND ($2::uuid IS NULL OR id = $2::uuid)
|
|
897
|
+
ORDER BY org_id NULLS LAST, id`, [auth.orgId, input.providerId ?? null]);
|
|
898
|
+
if (input.providerId !== undefined && providers[0] === undefined) {
|
|
899
|
+
return {
|
|
900
|
+
kind: 'provider',
|
|
901
|
+
detail: 'No embedding provider with that id is available to this organization.',
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
const own = providers.filter((p) => p.own);
|
|
905
|
+
if (input.providerId === undefined && own.length > 1) {
|
|
906
|
+
return {
|
|
907
|
+
kind: 'provider',
|
|
908
|
+
detail: `This organization has ${own.length} embedding providers, so which one a layer ` +
|
|
909
|
+
"is indexed with is not implied. Name it with 'provider_id'.",
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
const provider = own[0] ?? providers[0];
|
|
515
913
|
if (provider === undefined) {
|
|
516
914
|
throw new Error('no embedding provider is configured; a layer cannot be created without one');
|
|
517
915
|
}
|
|
@@ -521,10 +919,46 @@ export class PostgresLayers {
|
|
|
521
919
|
// collection does not have, which fails every upsert with `Bad
|
|
522
920
|
// Request` and would return nothing even if it did not.
|
|
523
921
|
const vector = vectorName(provider.model, provider.dimensions);
|
|
524
|
-
|
|
525
|
-
|
|
922
|
+
// Does the collection have room for this model?
|
|
923
|
+
//
|
|
924
|
+
// Usually yes: `init` creates the collection and the global provider
|
|
925
|
+
// from the same configuration, so every layer lands on the slot that is
|
|
926
|
+
// already there. An operator who adds a second provider is the case
|
|
927
|
+
// this exists for — `embedding_providers` has carried an `org_id` and a
|
|
928
|
+
// documented "NULL = global default" since the first migration, and a
|
|
929
|
+
// layer created against an organization's own provider used to accept
|
|
930
|
+
// documents and fail every one of them, forever, with the API reporting
|
|
931
|
+
// `queued` throughout.
|
|
932
|
+
const { rows: orgs } = await client.query('SELECT vector_collection FROM organizations WHERE id = $1 AND deleted_at IS NULL', [auth.orgId]);
|
|
933
|
+
const collection = orgs[0]?.vector_collection;
|
|
934
|
+
if (collection === undefined)
|
|
935
|
+
return { kind: 'denied' };
|
|
936
|
+
const present = await this.vectors.vectorsOf(collection);
|
|
937
|
+
// A copy in flight if the slot is missing. The layer is created either
|
|
938
|
+
// way — it is a real layer with a real provider — and the worker holds
|
|
939
|
+
// its documents at `pending` until the collection that can hold them
|
|
940
|
+
// exists. See `claimNext`.
|
|
941
|
+
//
|
|
942
|
+
// Reusing the reindex's own state rather than inventing a second
|
|
943
|
+
// mechanism: this *is* the copy phase of a reindex, with no documents
|
|
944
|
+
// to re-embed afterwards, so `finishCopy` and `finishReindexIfDone`
|
|
945
|
+
// close it out with nothing added.
|
|
946
|
+
const state = vector in present
|
|
947
|
+
? null
|
|
948
|
+
: toStateJson({
|
|
949
|
+
status: 'running',
|
|
950
|
+
phase: 'copying',
|
|
951
|
+
shadowVector: vector,
|
|
952
|
+
providerId: provider.id,
|
|
953
|
+
startedAt: new Date().toISOString(),
|
|
954
|
+
total: 0,
|
|
955
|
+
done: 0,
|
|
956
|
+
failed: 0,
|
|
957
|
+
});
|
|
958
|
+
const { rows } = await client.query(`INSERT INTO layers (org_id, workspace_id, slug, name, provider_id, vector_name, reindex_state)
|
|
959
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
526
960
|
ON CONFLICT DO NOTHING
|
|
527
|
-
RETURNING id, slug, name, workspace_id, description`, [auth.orgId, input.workspaceId, input.slug, input.name, provider.id, vector]);
|
|
961
|
+
RETURNING id, slug, name, workspace_id, description, created_at`, [auth.orgId, input.workspaceId, input.slug, input.name, provider.id, vector, state]);
|
|
528
962
|
const row = rows[0];
|
|
529
963
|
// ON CONFLICT DO NOTHING returns nothing, and by here the caller has
|
|
530
964
|
// already proved admin on the workspace — so the only way to get here
|
|
@@ -541,11 +975,175 @@ export class PostgresLayers {
|
|
|
541
975
|
description: row.description ?? '',
|
|
542
976
|
// Just created, so this is a fact rather than a query.
|
|
543
977
|
documentCount: 0,
|
|
978
|
+
createdAt: row.created_at.toISOString(),
|
|
979
|
+
},
|
|
980
|
+
};
|
|
981
|
+
}, this.scope);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Workspaces.
|
|
986
|
+
*
|
|
987
|
+
* The missing link in the API's own flow: creating a layer takes a
|
|
988
|
+
* `workspace_id`, and until this existed the only way to have one was the line
|
|
989
|
+
* `init` printed. `docs/quickstart.md` said as much — "the workspace id is the
|
|
990
|
+
* one `init` printed" — so a second administrator, a client library, or anyone
|
|
991
|
+
* who closed that terminal had no way forward that did not involve reading the
|
|
992
|
+
* database directly.
|
|
993
|
+
*/
|
|
994
|
+
export class PostgresWorkspaces {
|
|
995
|
+
pool;
|
|
996
|
+
role;
|
|
997
|
+
constructor(pool, role) {
|
|
998
|
+
this.pool = pool;
|
|
999
|
+
this.role = role;
|
|
1000
|
+
}
|
|
1001
|
+
get scope() {
|
|
1002
|
+
return this.role === undefined ? {} : { role: this.role };
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* The workspaces this caller can reach, newest last, cursor-paged.
|
|
1006
|
+
*
|
|
1007
|
+
* Two ways to reach one, and the second is the reason this endpoint had to
|
|
1008
|
+
* exist at all:
|
|
1009
|
+
*
|
|
1010
|
+
* 1. it holds a layer the read plan reaches — the ordinary case, and the same
|
|
1011
|
+
* narrowing the layer listing does;
|
|
1012
|
+
* 2. the caller holds a grant on the workspace itself. An **empty** workspace
|
|
1013
|
+
* has no layers to be reached through, so without this an administrator
|
|
1014
|
+
* who had just been granted one could not see it, and therefore could not
|
|
1015
|
+
* create the first layer in it. That is the exact deadlock this endpoint
|
|
1016
|
+
* is here to break.
|
|
1017
|
+
*
|
|
1018
|
+
* Filtered in memory rather than in SQL because the second rule is the
|
|
1019
|
+
* resolver's to answer, not a join's, and an organization has tens of
|
|
1020
|
+
* workspaces rather than thousands. The listing is narrowed before it is
|
|
1021
|
+
* paged — a page assembled first and filtered second would leak the count.
|
|
1022
|
+
*/
|
|
1023
|
+
async list(auth, page) {
|
|
1024
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1025
|
+
const context = await contextFor(client, auth);
|
|
1026
|
+
// No early return on `plan.kind === 'none'`, and that is the whole
|
|
1027
|
+
// point of this endpoint.
|
|
1028
|
+
//
|
|
1029
|
+
// `resolve` flattens a grant set to the layers it reaches, so a
|
|
1030
|
+
// principal whose only grant is `admin` on an **empty** workspace
|
|
1031
|
+
// resolves to `none` — there are no layers for it to name. Bailing out
|
|
1032
|
+
// here left exactly that caller unable to list the workspace they
|
|
1033
|
+
// administer, and therefore unable to create the first layer in it,
|
|
1034
|
+
// which is the deadlock this endpoint was added to break. It was
|
|
1035
|
+
// written that way first and caught by running it.
|
|
1036
|
+
const plan = resolve(context, 'read');
|
|
1037
|
+
const { rows } = await client.query(`SELECT w.id, w.slug, w.name, w.created_at,
|
|
1038
|
+
(SELECT array_agg(l.id) FROM layers l
|
|
1039
|
+
WHERE l.workspace_id = w.id AND l.deleted_at IS NULL) AS layer_ids
|
|
1040
|
+
FROM workspaces w
|
|
1041
|
+
WHERE w.org_id = $1 AND w.deleted_at IS NULL
|
|
1042
|
+
ORDER BY w.created_at, w.id`, [auth.orgId]);
|
|
1043
|
+
const reachable = rows.filter((w) => {
|
|
1044
|
+
if (plan.kind === 'all')
|
|
1045
|
+
return true;
|
|
1046
|
+
if (plan.kind === 'scoped' &&
|
|
1047
|
+
(w.layer_ids ?? []).some((id) => plan.layers.includes(id))) {
|
|
1048
|
+
return true;
|
|
1049
|
+
}
|
|
1050
|
+
// The grant on the workspace itself, which `admin` satisfies for
|
|
1051
|
+
// `read` — invariant 6's other half, and the only thing that answers
|
|
1052
|
+
// for a workspace with nothing in it yet.
|
|
1053
|
+
return referenceAllows(context, { type: 'workspace', id: w.id }, 'read');
|
|
1054
|
+
});
|
|
1055
|
+
const items = reachable.map((w) => ({
|
|
1056
|
+
id: w.id,
|
|
1057
|
+
slug: w.slug,
|
|
1058
|
+
name: w.name,
|
|
1059
|
+
layerCount: (w.layer_ids ?? []).length,
|
|
1060
|
+
createdAt: w.created_at.toISOString(),
|
|
1061
|
+
}));
|
|
1062
|
+
return pageOf(items, page, (w) => ({ createdAt: w.createdAt, id: w.id }));
|
|
1063
|
+
}, this.scope);
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Create a workspace. `org_admin` only.
|
|
1067
|
+
*
|
|
1068
|
+
* There is no scope above a workspace to hold a grant on — `scope_type` is
|
|
1069
|
+
* one of workspace, layer, document — so there is no one to ask except the
|
|
1070
|
+
* role. `referenceAllows` would answer this with `role === 'org_admin'` and
|
|
1071
|
+
* nothing else, so the check is written plainly rather than routed through a
|
|
1072
|
+
* resolver call that can only return one thing.
|
|
1073
|
+
*
|
|
1074
|
+
* `platform_admin` is refused along with everyone else. Rule 2: it
|
|
1075
|
+
* administers organizations and reads no documents, and a workspace is where
|
|
1076
|
+
* documents live.
|
|
1077
|
+
*/
|
|
1078
|
+
async create(auth, input) {
|
|
1079
|
+
if (auth.role !== 'org_admin')
|
|
1080
|
+
return { kind: 'denied' };
|
|
1081
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1082
|
+
const { rows } = await client.query(`INSERT INTO workspaces (org_id, slug, name) VALUES ($1,$2,$3)
|
|
1083
|
+
ON CONFLICT DO NOTHING
|
|
1084
|
+
RETURNING id, slug, name, created_at`, [auth.orgId, input.slug, input.name]);
|
|
1085
|
+
const row = rows[0];
|
|
1086
|
+
// ON CONFLICT DO NOTHING returns nothing, and by here the caller is
|
|
1087
|
+
// org_admin — so the only way to get here is a slug already in use.
|
|
1088
|
+
// 409 rather than 404, as on layer create: the caller has proved they
|
|
1089
|
+
// administer the organization, so this is a fact about the resource
|
|
1090
|
+
// rather than about what they can see.
|
|
1091
|
+
if (row === undefined)
|
|
1092
|
+
return { kind: 'conflict' };
|
|
1093
|
+
return {
|
|
1094
|
+
kind: 'created',
|
|
1095
|
+
workspace: {
|
|
1096
|
+
id: row.id,
|
|
1097
|
+
slug: row.slug,
|
|
1098
|
+
name: row.name,
|
|
1099
|
+
layerCount: 0,
|
|
1100
|
+
createdAt: row.created_at.toISOString(),
|
|
544
1101
|
},
|
|
545
1102
|
};
|
|
546
1103
|
}, this.scope);
|
|
547
1104
|
}
|
|
548
1105
|
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Rename a layer, or change what it says it is for.
|
|
1108
|
+
*
|
|
1109
|
+
* `admin` on the workspace, which is what creating one takes — the same
|
|
1110
|
+
* question asked of the same resolver, so the two cannot drift into "who may
|
|
1111
|
+
* create" and "who may rename" meaning different things.
|
|
1112
|
+
*
|
|
1113
|
+
* The description is user-facing copy: it is what the MCP tool's generated
|
|
1114
|
+
* description is built from, so an agent's picture of what a layer holds
|
|
1115
|
+
* changes with it. That is the reason it is editable at all.
|
|
1116
|
+
*/
|
|
1117
|
+
async function updateLayer(pool, auth, layerId, input, role) {
|
|
1118
|
+
if (!/^[0-9a-f-]{36}$/i.test(layerId))
|
|
1119
|
+
return false;
|
|
1120
|
+
if (input.name === undefined && input.description === undefined)
|
|
1121
|
+
return false;
|
|
1122
|
+
return withOrg(pool, auth.orgId, async (client) => {
|
|
1123
|
+
const context = await contextFor(client, auth);
|
|
1124
|
+
// The layer's workspace, before anything is decided — the grant is on the
|
|
1125
|
+
// workspace, so there is nothing to ask until we know which one.
|
|
1126
|
+
const { rows } = await client.query(`SELECT workspace_id FROM layers
|
|
1127
|
+
WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL`, [auth.orgId, layerId]);
|
|
1128
|
+
const workspaceId = rows[0]?.workspace_id;
|
|
1129
|
+
if (workspaceId === undefined)
|
|
1130
|
+
return false;
|
|
1131
|
+
// `referenceAllows` on the workspace rather than a flattened plan, for
|
|
1132
|
+
// the reason layer creation gives: the question is "may this caller
|
|
1133
|
+
// administer this workspace", which the reference answers directly, and
|
|
1134
|
+
// inferring it from the layers a plan happens to contain gets an empty
|
|
1135
|
+
// workspace wrong.
|
|
1136
|
+
if (!referenceAllows(context, { type: 'workspace', id: workspaceId }, 'admin'))
|
|
1137
|
+
return false;
|
|
1138
|
+
// COALESCE, so an absent field is left alone rather than blanked. PATCH
|
|
1139
|
+
// changes what it names; a missing key is not an instruction to erase.
|
|
1140
|
+
await client.query(`UPDATE layers
|
|
1141
|
+
SET name = COALESCE($3, name),
|
|
1142
|
+
description = COALESCE($4, description)
|
|
1143
|
+
WHERE org_id = $1 AND id = $2`, [auth.orgId, layerId, input.name ?? null, input.description ?? null]);
|
|
1144
|
+
return true;
|
|
1145
|
+
}, role === undefined ? {} : { role });
|
|
1146
|
+
}
|
|
549
1147
|
export class PostgresGrants {
|
|
550
1148
|
pool;
|
|
551
1149
|
role;
|
|
@@ -564,15 +1162,32 @@ export class PostgresGrants {
|
|
|
564
1162
|
* than none — an administrator who cannot see an existing grant revokes the
|
|
565
1163
|
* wrong thing.
|
|
566
1164
|
*/
|
|
567
|
-
|
|
1165
|
+
/**
|
|
1166
|
+
* Paginated, and the page may be short.
|
|
1167
|
+
*
|
|
1168
|
+
* Which grants a caller may see is decided by `referenceAllows` against the
|
|
1169
|
+
* scope tree, which SQL cannot express — so rows are fetched by cursor and
|
|
1170
|
+
* filtered afterwards. That makes `items.length < limit` normal rather than a
|
|
1171
|
+
* signal, and `next_cursor` the only thing that says whether more exist.
|
|
1172
|
+
*
|
|
1173
|
+
* The cursor comes from the last row **fetched**, not the last returned.
|
|
1174
|
+
* Deriving it from the last returned row would skip everything the filter
|
|
1175
|
+
* removed after it, which is a page of grants silently missing from an
|
|
1176
|
+
* administrator's view of who can reach what.
|
|
1177
|
+
*/
|
|
1178
|
+
async list(auth, page) {
|
|
568
1179
|
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
569
1180
|
const context = await contextFor(client, auth);
|
|
570
1181
|
const plan = resolve(context, 'admin');
|
|
571
1182
|
if (plan.kind === 'none')
|
|
572
|
-
return [];
|
|
573
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
1183
|
+
return { items: [], nextCursor: null };
|
|
1184
|
+
const after = page?.after;
|
|
1185
|
+
const seek = after === undefined ? '' : ' AND (created_at, id) > ($2::timestamptz, $3::uuid)';
|
|
1186
|
+
const cap = page === undefined ? '' : ` LIMIT ${page.limit}`;
|
|
1187
|
+
const { rows } = await client.query(`SELECT id, principal_type, principal_id, scope_type, scope_id, permission, effect, source,
|
|
1188
|
+
created_at
|
|
1189
|
+
FROM grants WHERE org_id = $1${seek} ORDER BY created_at, id${cap}`, after === undefined ? [auth.orgId] : [auth.orgId, after.createdAt, after.id]);
|
|
1190
|
+
const visible = rows
|
|
576
1191
|
.filter((r) => referenceAllows(context, { type: r.scope_type, id: r.scope_id }, 'admin'))
|
|
577
1192
|
.map((r) => ({
|
|
578
1193
|
id: r.id,
|
|
@@ -584,6 +1199,15 @@ export class PostgresGrants {
|
|
|
584
1199
|
effect: r.effect,
|
|
585
1200
|
source: r.source,
|
|
586
1201
|
}));
|
|
1202
|
+
// From the last row fetched rather than the last returned. The filter
|
|
1203
|
+
// above removes rows the caller may not administer, and taking the
|
|
1204
|
+
// cursor from a survivor would skip every row the filter dropped after
|
|
1205
|
+
// it — a page of grants silently missing from an administrator's view.
|
|
1206
|
+
const lastFetched = rows[rows.length - 1];
|
|
1207
|
+
const nextCursor = page !== undefined && rows.length >= page.limit && lastFetched !== undefined
|
|
1208
|
+
? encodeCursor({ createdAt: lastFetched.created_at.toISOString(), id: lastFetched.id })
|
|
1209
|
+
: null;
|
|
1210
|
+
return { items: visible, nextCursor };
|
|
587
1211
|
}, this.scope);
|
|
588
1212
|
}
|
|
589
1213
|
async issue(auth, input) {
|
|
@@ -669,4 +1293,320 @@ export class PostgresGrants {
|
|
|
669
1293
|
}, this.scope);
|
|
670
1294
|
}
|
|
671
1295
|
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Reading the access log back.
|
|
1298
|
+
*
|
|
1299
|
+
* Scoped through `withOrg` like every other tenant read, so the row-level
|
|
1300
|
+
* policy on `audit_events` is the second line of defence behind the role check
|
|
1301
|
+
* in the handler. `SELECT` is the only grant the application role has on this
|
|
1302
|
+
* table and that stays true — nothing here writes.
|
|
1303
|
+
*
|
|
1304
|
+
* ## Newest first, and what that does to the cursor
|
|
1305
|
+
*
|
|
1306
|
+
* Every other paged collection here reads oldest-first, because a catalog is a
|
|
1307
|
+
* set and the order is arbitrary. A log is not: the useful end is the recent
|
|
1308
|
+
* one, and an auditor paging forward from the beginning of a 400-day retention
|
|
1309
|
+
* window to find yesterday is not a feature. So this orders
|
|
1310
|
+
* `(occurred_at, id) DESC` and the seek predicate is `<` rather than `>`.
|
|
1311
|
+
*
|
|
1312
|
+
* The cursor still means "everything after the last row you saw", which is the
|
|
1313
|
+
* same contract read in the other direction. It is worth being explicit because
|
|
1314
|
+
* copying the seek clause from the layers adapter — where it is `>` — would
|
|
1315
|
+
* produce a first page that works and a second page that silently returns the
|
|
1316
|
+
* beginning of the log.
|
|
1317
|
+
*/
|
|
1318
|
+
export class PostgresAuditReader {
|
|
1319
|
+
pool;
|
|
1320
|
+
role;
|
|
1321
|
+
constructor(pool, role) {
|
|
1322
|
+
this.pool = pool;
|
|
1323
|
+
this.role = role;
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Actions that record a substantive access to a document's contents.
|
|
1327
|
+
*
|
|
1328
|
+
* Withheld from `platform_admin`, per rule 2 in docs/authz.md as
|
|
1329
|
+
* docs/audit.md applies it to the journal. Named as a deny-list rather than
|
|
1330
|
+
* an allow-list of administrative actions, and that is the uncomfortable
|
|
1331
|
+
* direction: a new action defaults to *visible* to a platform administrator
|
|
1332
|
+
* rather than hidden.
|
|
1333
|
+
*
|
|
1334
|
+
* Chosen anyway, because the alternative fails worse. An allow-list means a
|
|
1335
|
+
* new administrative action is invisible to the operator who administers the
|
|
1336
|
+
* installation until someone remembers to add it — a silent gap in an
|
|
1337
|
+
* operational tool. This way a new *access* action is visible until someone
|
|
1338
|
+
* adds it here, which is a disclosure to an already highly-privileged role
|
|
1339
|
+
* within one installation. Both are bugs; only one of them is quiet.
|
|
1340
|
+
*
|
|
1341
|
+
* The `audit-event` skill's checklist is where this list is kept in step.
|
|
1342
|
+
*/
|
|
1343
|
+
static DOCUMENT_ACCESS = ['search', 'document.read', 'document.get', 'chunk.read'];
|
|
1344
|
+
async read(auth, query, page) {
|
|
1345
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1346
|
+
const where = ['org_id = $1'];
|
|
1347
|
+
const params = [auth.orgId];
|
|
1348
|
+
const bind = (value) => {
|
|
1349
|
+
params.push(value);
|
|
1350
|
+
return `$${params.length}`;
|
|
1351
|
+
};
|
|
1352
|
+
if (query.from !== undefined)
|
|
1353
|
+
where.push(`occurred_at >= ${bind(query.from)}::timestamptz`);
|
|
1354
|
+
if (query.to !== undefined)
|
|
1355
|
+
where.push(`occurred_at < ${bind(query.to)}::timestamptz`);
|
|
1356
|
+
if (query.actorId !== undefined)
|
|
1357
|
+
where.push(`actor_id = ${bind(query.actorId)}::uuid`);
|
|
1358
|
+
if (query.action !== undefined)
|
|
1359
|
+
where.push(`action = ${bind(query.action)}`);
|
|
1360
|
+
if (query.result !== undefined)
|
|
1361
|
+
where.push(`result = ${bind(query.result)}`);
|
|
1362
|
+
if (query.administrativeOnly === true) {
|
|
1363
|
+
where.push(`action <> ALL(${bind(PostgresAuditReader.DOCUMENT_ACCESS)}::text[])`);
|
|
1364
|
+
}
|
|
1365
|
+
// Descending, and `<` to match. See the note on the class.
|
|
1366
|
+
if (page.after !== undefined) {
|
|
1367
|
+
where.push(`(occurred_at, id) < (${bind(page.after.createdAt)}::timestamptz, ${bind(page.after.id)}::bigint)`);
|
|
1368
|
+
}
|
|
1369
|
+
// One more than asked for, so "is there another page" is answered
|
|
1370
|
+
// without a second query and without a count — a count over a
|
|
1371
|
+
// retention window is the expensive thing this endpoint must not do on
|
|
1372
|
+
// every request.
|
|
1373
|
+
const { rows } = await client.query(`SELECT id::text, occurred_at, actor_type, actor_id, actor_label, action,
|
|
1374
|
+
surface, client, target, result, detail, request_id
|
|
1375
|
+
FROM audit_events
|
|
1376
|
+
WHERE ${where.join(' AND ')}
|
|
1377
|
+
ORDER BY occurred_at DESC, id DESC
|
|
1378
|
+
LIMIT ${page.limit + 1}`, params);
|
|
1379
|
+
const items = rows.slice(0, page.limit).map((r) => ({
|
|
1380
|
+
id: r.id,
|
|
1381
|
+
occurredAt: r.occurred_at.toISOString(),
|
|
1382
|
+
actorType: r.actor_type,
|
|
1383
|
+
actorId: r.actor_id,
|
|
1384
|
+
actorLabel: r.actor_label,
|
|
1385
|
+
action: r.action,
|
|
1386
|
+
surface: r.surface,
|
|
1387
|
+
client: r.client,
|
|
1388
|
+
target: r.target ?? {},
|
|
1389
|
+
result: r.result,
|
|
1390
|
+
detail: r.detail ?? {},
|
|
1391
|
+
requestId: r.request_id,
|
|
1392
|
+
}));
|
|
1393
|
+
const last = items[items.length - 1];
|
|
1394
|
+
const nextCursor = rows.length > page.limit && last !== undefined
|
|
1395
|
+
? encodeCursor({ createdAt: last.occurredAt, id: last.id })
|
|
1396
|
+
: null;
|
|
1397
|
+
return { items, nextCursor };
|
|
1398
|
+
}, this.scopeFor());
|
|
1399
|
+
}
|
|
1400
|
+
scopeFor() {
|
|
1401
|
+
return this.role === undefined ? {} : { role: this.role };
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Starting a reindex, and reporting on one.
|
|
1406
|
+
*
|
|
1407
|
+
* The work is the worker's. This writes the intent — `layers.reindex_state` —
|
|
1408
|
+
* counts what there is to do, and declares the new named vector on the
|
|
1409
|
+
* collection.
|
|
1410
|
+
*
|
|
1411
|
+
* That last part belongs here and an earlier version of this comment said the
|
|
1412
|
+
* opposite: that Qdrant creates a named vector when a point is first written
|
|
1413
|
+
* with it, so the worker's first batch would bring it into existence. It does
|
|
1414
|
+
* not. `updateVectors` against an undeclared name answers
|
|
1415
|
+
* `Not existing vector name error`, and the reindex that assumed otherwise
|
|
1416
|
+
* started cleanly, reported `running`, and failed every document forever with
|
|
1417
|
+
* the progress gauge sitting at zero — which is step 1 of the sequence in
|
|
1418
|
+
* docs/architecture.md being skipped, and the document has it first for a
|
|
1419
|
+
* reason.
|
|
1420
|
+
*
|
|
1421
|
+
* Doing it here rather than in the worker also puts the failure where somebody
|
|
1422
|
+
* is looking: an operator who cannot create the vector finds out from the
|
|
1423
|
+
* response to their own request, not by watching a counter not move.
|
|
1424
|
+
*/
|
|
1425
|
+
export class PostgresReindex {
|
|
1426
|
+
pool;
|
|
1427
|
+
vectors;
|
|
1428
|
+
role;
|
|
1429
|
+
constructor(pool, vectors, role) {
|
|
1430
|
+
this.pool = pool;
|
|
1431
|
+
this.vectors = vectors;
|
|
1432
|
+
this.role = role;
|
|
1433
|
+
}
|
|
1434
|
+
get scope() {
|
|
1435
|
+
return this.role === undefined ? {} : { role: this.role };
|
|
1436
|
+
}
|
|
1437
|
+
async start(auth, layerId, providerId) {
|
|
1438
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1439
|
+
// Administering a layer, not reading it. A reindex rewrites every
|
|
1440
|
+
// vector in the layer and changes which model answers every future
|
|
1441
|
+
// query — `read` is nowhere near enough, and `write` is about putting
|
|
1442
|
+
// documents in rather than about the layer itself.
|
|
1443
|
+
const plan = resolve(await contextFor(client, auth), 'admin');
|
|
1444
|
+
if (plan.kind === 'none')
|
|
1445
|
+
return undefined;
|
|
1446
|
+
// Locked for the whole decision. Two starts arriving together would
|
|
1447
|
+
// both read "no reindex running" and both write a state, and the loser
|
|
1448
|
+
// would leave a shadow vector nothing is filling.
|
|
1449
|
+
const { rows: layers } = await client.query(`SELECT id, vector_name, reindex_state FROM layers
|
|
1450
|
+
WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL
|
|
1451
|
+
FOR UPDATE`, [auth.orgId, layerId]);
|
|
1452
|
+
const layer = layers[0];
|
|
1453
|
+
if (layer === undefined)
|
|
1454
|
+
return undefined;
|
|
1455
|
+
if (plan.kind === 'scoped' && !plan.layers.includes(layer.id))
|
|
1456
|
+
return undefined;
|
|
1457
|
+
const running = fromStateJson(layer.reindex_state);
|
|
1458
|
+
if (running?.status === 'running') {
|
|
1459
|
+
// The live remaining count, not zero. A caller who collides with a
|
|
1460
|
+
// running reindex is asking "how far has it got" as much as "why was
|
|
1461
|
+
// I refused", and the answer they get should be the same one `GET`
|
|
1462
|
+
// gives — not a placeholder that reads as finished.
|
|
1463
|
+
const remaining = await this.remaining(client, auth.orgId, layerId, running.shadowVector);
|
|
1464
|
+
return {
|
|
1465
|
+
kind: 'conflict',
|
|
1466
|
+
status: this.statusOf(layerId, layer.vector_name, running, remaining),
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
// The provider may be the organization's own or the installation-wide
|
|
1470
|
+
// default, which is the one row with a NULL org_id. Same rule the
|
|
1471
|
+
// policy on that table encodes; repeated here because a caller naming
|
|
1472
|
+
// another tenant's provider must get "no such provider" rather than a
|
|
1473
|
+
// reindex onto a model they cannot reach.
|
|
1474
|
+
const { rows: providers } = await client.query(`SELECT model, dimensions FROM embedding_providers
|
|
1475
|
+
WHERE id = $1 AND (org_id = $2 OR org_id IS NULL)`, [providerId, auth.orgId]);
|
|
1476
|
+
const provider = providers[0];
|
|
1477
|
+
if (provider === undefined)
|
|
1478
|
+
return { kind: 'unknown_provider' };
|
|
1479
|
+
const shadow = vectorName(provider.model, provider.dimensions);
|
|
1480
|
+
if (shadow === layer.vector_name) {
|
|
1481
|
+
return { kind: 'already_current', vectorName: shadow };
|
|
1482
|
+
}
|
|
1483
|
+
// Counted now, and it is a starting figure rather than a bound.
|
|
1484
|
+
// Documents ingested during the reindex have no shadow vector either,
|
|
1485
|
+
// so the same pass picks them up and `done` can pass `total` — which is
|
|
1486
|
+
// why the progress ratio is clamped rather than trusted.
|
|
1487
|
+
// Which half this layer starts in.
|
|
1488
|
+
//
|
|
1489
|
+
// A named vector cannot be added to a live Qdrant collection, so the
|
|
1490
|
+
// slot has to already exist. If the organization's collection has it —
|
|
1491
|
+
// because an earlier reindex made it — this layer goes straight to
|
|
1492
|
+
// embedding. If not, the collection has to be rebuilt with room for it
|
|
1493
|
+
// first, and that is org-wide work the worker does.
|
|
1494
|
+
const { rows: orgs } = await client.query('SELECT vector_collection FROM organizations WHERE id = $1', [auth.orgId]);
|
|
1495
|
+
const collection = orgs[0]?.vector_collection;
|
|
1496
|
+
if (collection === undefined)
|
|
1497
|
+
return undefined;
|
|
1498
|
+
const present = await this.vectors.vectorsOf(collection);
|
|
1499
|
+
const width = present[shadow];
|
|
1500
|
+
if (width !== undefined && width !== provider.dimensions) {
|
|
1501
|
+
// Same name, different width. The name is derived from model and
|
|
1502
|
+
// dimensions so this cannot come from the API — it means the vector
|
|
1503
|
+
// was made by hand, and writing 768 values into a 1024-wide slot is
|
|
1504
|
+
// the kind of failure that surfaces months later as bad recall.
|
|
1505
|
+
return { kind: 'unknown_provider' };
|
|
1506
|
+
}
|
|
1507
|
+
const phase = width === undefined ? 'copying' : 'embedding';
|
|
1508
|
+
// One copy at a time for an organization. Two layers starting reindexes
|
|
1509
|
+
// onto two different models would otherwise each rebuild the
|
|
1510
|
+
// collection, and the second would throw away the first's work along
|
|
1511
|
+
// with the pointer that made it live.
|
|
1512
|
+
if (phase === 'copying') {
|
|
1513
|
+
const { rows: busy } = await client.query(`SELECT count(*)::text AS n FROM layers
|
|
1514
|
+
WHERE org_id = $1 AND deleted_at IS NULL
|
|
1515
|
+
AND reindex_state ->> 'status' = 'running'
|
|
1516
|
+
AND reindex_state ->> 'phase' = 'copying'`, [auth.orgId]);
|
|
1517
|
+
if (Number(busy[0]?.n ?? 0) > 0) {
|
|
1518
|
+
const other = fromStateJson(layer.reindex_state) ?? {
|
|
1519
|
+
status: 'running',
|
|
1520
|
+
phase: 'copying',
|
|
1521
|
+
shadowVector: shadow,
|
|
1522
|
+
providerId,
|
|
1523
|
+
startedAt: new Date().toISOString(),
|
|
1524
|
+
total: 0,
|
|
1525
|
+
done: 0,
|
|
1526
|
+
failed: 0,
|
|
1527
|
+
};
|
|
1528
|
+
return { kind: 'conflict', status: this.statusOf(layerId, layer.vector_name, other, 0) };
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
const total = await this.remaining(client, auth.orgId, layerId, shadow);
|
|
1532
|
+
const state = {
|
|
1533
|
+
status: 'running',
|
|
1534
|
+
phase,
|
|
1535
|
+
shadowVector: shadow,
|
|
1536
|
+
providerId,
|
|
1537
|
+
startedAt: new Date().toISOString(),
|
|
1538
|
+
total,
|
|
1539
|
+
done: 0,
|
|
1540
|
+
failed: 0,
|
|
1541
|
+
};
|
|
1542
|
+
await client.query('UPDATE layers SET reindex_state = $3::jsonb WHERE org_id = $1 AND id = $2', [auth.orgId, layerId, JSON.stringify(toStateJson(state))]);
|
|
1543
|
+
// `remaining = total`: nothing has been reindexed yet, so `done` is 0.
|
|
1544
|
+
// Passing 0 here said `done: total, progress: 1` on the 202 — a reindex
|
|
1545
|
+
// reporting itself finished in the same breath as starting.
|
|
1546
|
+
return {
|
|
1547
|
+
kind: 'started',
|
|
1548
|
+
status: this.statusOf(layerId, layer.vector_name, state, total),
|
|
1549
|
+
};
|
|
1550
|
+
}, this.scope);
|
|
1551
|
+
}
|
|
1552
|
+
async status(auth, layerId) {
|
|
1553
|
+
return withOrg(this.pool, auth.orgId, async (client) => {
|
|
1554
|
+
// `read` here, not `admin`. Knowing that the layer you can search is
|
|
1555
|
+
// being migrated is not an administrative fact — it explains why a
|
|
1556
|
+
// result set moved — and hiding it from someone who can already read
|
|
1557
|
+
// the layer buys nothing.
|
|
1558
|
+
const plan = resolve(await contextFor(client, auth), 'read');
|
|
1559
|
+
if (plan.kind === 'none')
|
|
1560
|
+
return undefined;
|
|
1561
|
+
const { rows } = await client.query(`SELECT id, vector_name, reindex_state FROM layers
|
|
1562
|
+
WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL`, [auth.orgId, layerId]);
|
|
1563
|
+
const layer = rows[0];
|
|
1564
|
+
if (layer === undefined)
|
|
1565
|
+
return undefined;
|
|
1566
|
+
if (plan.kind === 'scoped' && !plan.layers.includes(layer.id))
|
|
1567
|
+
return undefined;
|
|
1568
|
+
const state = fromStateJson(layer.reindex_state);
|
|
1569
|
+
if (state === undefined)
|
|
1570
|
+
return undefined;
|
|
1571
|
+
// The live remaining count rather than a stored `done`, because the
|
|
1572
|
+
// worker never writes one — the number is derived, so there is nothing
|
|
1573
|
+
// to go stale between batches.
|
|
1574
|
+
const left = await this.remaining(client, auth.orgId, layerId, state.shadowVector);
|
|
1575
|
+
return this.statusOf(layerId, layer.vector_name, state, left);
|
|
1576
|
+
}, this.scope);
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Live documents in the layer that do not yet carry this vector.
|
|
1580
|
+
*
|
|
1581
|
+
* The same predicate the worker's claim uses and the same one the switch is
|
|
1582
|
+
* guarded by. Three places, one meaning — and if they ever disagree, the
|
|
1583
|
+
* switch is the one that is right, because it is the only one inside the
|
|
1584
|
+
* statement that performs it.
|
|
1585
|
+
*/
|
|
1586
|
+
async remaining(client, orgId, layerId, shadowVector) {
|
|
1587
|
+
const { rows } = await client.query(`SELECT count(*)::text AS n FROM documents
|
|
1588
|
+
WHERE org_id = $1 AND layer_id = $2 AND deleted_at IS NULL AND chunk_count > 0
|
|
1589
|
+
AND reindexed_vector IS DISTINCT FROM $3`, [orgId, layerId, shadowVector]);
|
|
1590
|
+
return Number(rows[0]?.n ?? 0);
|
|
1591
|
+
}
|
|
1592
|
+
statusOf(layerId, currentVector, state, remaining) {
|
|
1593
|
+
const done = state.status === 'complete' ? state.total : Math.max(0, state.total - remaining);
|
|
1594
|
+
const live = { ...state, done };
|
|
1595
|
+
return {
|
|
1596
|
+
layerId,
|
|
1597
|
+
status: state.status,
|
|
1598
|
+
phase: state.phase,
|
|
1599
|
+
shadowVector: state.shadowVector,
|
|
1600
|
+
currentVector,
|
|
1601
|
+
providerId: state.providerId,
|
|
1602
|
+
startedAt: state.startedAt,
|
|
1603
|
+
finishedAt: state.finishedAt ?? null,
|
|
1604
|
+
total: state.total,
|
|
1605
|
+
done,
|
|
1606
|
+
failed: state.failed,
|
|
1607
|
+
progress: reindexProgress(live),
|
|
1608
|
+
error: state.error ?? null,
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
672
1612
|
//# sourceMappingURL=adapters.js.map
|