@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.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/dist/adapters.d.ts +326 -15
  4. package/dist/adapters.d.ts.map +1 -1
  5. package/dist/adapters.js +992 -52
  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 +11 -2
  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 +136 -53
  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 +345 -6
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +1035 -24
  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,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, role) {
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, d.updated_at
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 slug = await this.deps.orgSlug(auth.orgId);
68
- if (slug === undefined) {
69
- // The token names an organization that is not there. Rule I3: a
70
- // permission that cannot be evaluated denies.
71
- return [];
72
- }
73
- const plan = await withOrg(this.deps.pool, auth.orgId, async (client) => {
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
- return resolve({ orgId: auth.orgId, role: auth.role, principals, grants, tree }, 'read');
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
- const [vector] = await this.deps.embedder.embed([query]);
87
- if (vector === undefined)
88
- throw new Error('the embedder returned no vector for the query');
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
- orgSlug: slug,
253
+ collection,
92
254
  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,
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
- return this.hydrate(auth.orgId, checked);
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(`INSERT INTO audit_events
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,'api','{}'::jsonb,$6,$7,$8)`, [
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
- constructor(endpoint, model, dimensions) {
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(slug, documentId);
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, role) {
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 ORDER BY l.slug`, [auth.orgId])
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
- ORDER BY l.slug`, [auth.orgId, [...plan.layers]]);
484
- return rows.map((r) => ({
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
- 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];
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
- 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)
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
- async list(auth) {
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 { 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
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