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