@johpaz/hive-sdk 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -230,4 +230,4 @@ npm view @johpaz/hive-sdk dist-tags # verificar después del release
230
230
 
231
231
  ---
232
232
 
233
- *Hive SDK v0.3.1 — MIT*
233
+ *Hive SDK v0.4.0 — MIT*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johpaz/hive-sdk",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Hive SDK — The Agent Harness SDK. Build, deploy, and scale AI agent applications with multi-channel support, context engineering, and swarm orchestration.",
6
6
  "license": "MIT",
@@ -20,6 +20,7 @@
20
20
 
21
21
  import type { IndexDoc } from "@johpaz/hive-db";
22
22
  import { getHiveDb } from "../storage/hivedb.ts";
23
+ import { currentTenant, qualifyDocId, unqualifyDocId, scopedFilterValue } from "../storage/tenant.ts";
23
24
  import { logger } from "../utils/logger.ts";
24
25
 
25
26
  const log = logger.child("capability-search");
@@ -87,12 +88,19 @@ export async function searchCapabilities(
87
88
 
88
89
  // Filters are AND-ed by the engine, so multi-type search runs one query per
89
90
  // type; the single-type and all-types cases are one engine call.
91
+ //
92
+ // El índice semántico es UNO solo para todos los inquilinos (no hay
93
+ // "colección" que prefijar), así que el tenant entra como un filtro más que
94
+ // el motor AND-ea con el resto. Sin tenant no se añade nada: así un índice ya
95
+ // construido por la app de escritorio sigue respondiendo igual.
96
+ const tenant = currentTenant();
97
+ const tenantFilter = tenant ? [{ field: "tenant", value: tenant }] : [];
90
98
  const queries = types
91
99
  ? types.map((t) => ({
92
100
  type: t,
93
- filters: [{ field: "type", value: t }],
101
+ filters: [...tenantFilter, { field: "type", value: t }],
94
102
  }))
95
- : [{ type: undefined, filters: undefined }];
103
+ : [{ type: undefined, filters: tenantFilter.length ? tenantFilter : undefined }];
96
104
 
97
105
  const merged = new Map<string, CapabilityHit>();
98
106
  for (const q of queries) {
@@ -103,12 +111,15 @@ export async function searchCapabilities(
103
111
  boosts: opts.boosts,
104
112
  });
105
113
  for (const hit of hits) {
106
- const parsed = splitId(hit.id);
114
+ // El id indexado lleva el prefijo del tenant; quien consume esto espera
115
+ // el id canónico ("tool:web_search"), no la forma física.
116
+ const id = unqualifyDocId(hit.id);
117
+ const parsed = splitId(id);
107
118
  if (!parsed) continue;
108
- const existing = merged.get(hit.id);
119
+ const existing = merged.get(id);
109
120
  if (!existing || hit.score > existing.score) {
110
- merged.set(hit.id, {
111
- id: hit.id,
121
+ merged.set(id, {
122
+ id,
112
123
  type: parsed.type,
113
124
  rawId: parsed.rawId,
114
125
  score: hit.score,
@@ -154,7 +165,15 @@ export async function replaceCapabilityDocs(
154
165
  docs: CapabilityDoc[]
155
166
  ): Promise<void> {
156
167
  const db = await getHiveDb();
157
- await db.deleteByFilter({ field: "type", value: type });
168
+ // `deleteByFilter` acepta UN SOLO filtro, así que en una base compartida
169
+ // borrar por `type` se llevaría por delante los documentos de todos los
170
+ // inquilinos. El campo sintético `tenant__type` (ver scopedFilterValue)
171
+ // mantiene el borrado en un filtro y acotado a este tenant.
172
+ await db.deleteByFilter(
173
+ currentTenant()
174
+ ? { field: "tenant__type", value: scopedFilterValue(type) }
175
+ : { field: "type", value: type }
176
+ );
158
177
  if (docs.length === 0) return;
159
178
  await db.upsertBatch(docs.map(toIndexDoc));
160
179
  }
@@ -169,18 +188,38 @@ export async function upsertCapabilityDocs(docs: CapabilityDoc[]): Promise<void>
169
188
  /** Delete every MCP doc belonging to a server (hot-reload/disconnect). */
170
189
  export async function deleteCapabilitiesByServer(serverId: string): Promise<void> {
171
190
  const db = await getHiveDb();
172
- await db.deleteByFilter({ field: "server_id", value: serverId });
191
+ await db.deleteByFilter(
192
+ currentTenant()
193
+ ? { field: "tenant__server_id", value: scopedFilterValue(serverId) }
194
+ : { field: "server_id", value: serverId }
195
+ );
173
196
  }
174
197
 
175
198
  function toIndexDoc(doc: CapabilityDoc): IndexDoc {
199
+ const extra = doc.extraFilters ?? [];
200
+ const tenant = currentTenant();
176
201
  return {
177
- id: `${doc.type}:${doc.rawId}`,
202
+ id: qualifyDocId(`${doc.type}:${doc.rawId}`),
178
203
  name: doc.name,
179
204
  body: doc.body,
180
205
  tags: doc.tags,
181
206
  filters: [
182
207
  { field: "type", value: doc.type },
183
- ...(doc.extraFilters ?? []),
208
+ ...extra,
209
+ // Con tenant activo, cada filtro lleva además un gemelo `tenant__<campo>`.
210
+ // Es lo que permite que los borrados masivos —que sólo aceptan un
211
+ // filtro— sigan acotados a este inquilino. Sin tenant no se emite nada,
212
+ // así que un índice ya construido conserva exactamente su forma.
213
+ ...(tenant
214
+ ? [
215
+ { field: "tenant", value: tenant },
216
+ { field: "tenant__type", value: scopedFilterValue(doc.type) },
217
+ ...extra.map((f) => ({
218
+ field: `tenant__${f.field}`,
219
+ value: scopedFilterValue(f.value),
220
+ })),
221
+ ]
222
+ : []),
184
223
  ],
185
224
  };
186
225
  }
@@ -37,8 +37,8 @@ import { resolveUserId } from "../storage/onboarding.ts"
37
37
  import { getMCPManager as getSingletonMCPManager } from "../mcp/singleton.ts"
38
38
  import { syncMCPToolsToDB, syncMCPToolsToIndex } from "../mcp/tool-sync.ts"
39
39
  import { getUserDate, getUserTime } from "../utils/date.ts"
40
- import { loadConfig } from "../config/loader.ts"
41
40
  import { getHiveDb } from "../storage/hivedb.ts"
41
+ import { causalReadsEnabled } from "../storage/causal-events.ts"
42
42
  import { listCatalogAgents, renderAgentRoutingCatalog } from "./catalog-selector.ts"
43
43
  import { expandToolAllowlist } from "./delegation-runtime.ts"
44
44
  import { MINIMAL_TOOLS } from "./minimal-loadout.ts"
@@ -588,7 +588,7 @@ export async function compileContext(opts: {
588
588
  // applies this turn (a real DB round-trip, not a per-turn cost) and
589
589
  // there's a causal stream to build it from. episodicSimilarity is omitted:
590
590
  // it requires embeddings hive doesn't generate anywhere yet.
591
- if (summaryApplies && opts.causalStreamId && loadConfig().causalLog?.enabled) {
591
+ if (summaryApplies && opts.causalStreamId && causalReadsEnabled()) {
592
592
  try {
593
593
  const causalDb = await getHiveDb()
594
594
  const objectiveSource = taskContext || userMessage
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import { col, nextId, bumpRollup } from "../storage/hive.ts"
9
- import { getHiveDb } from "../storage/hivedb.ts"
10
9
  import { logger } from "../utils/logger.ts"
11
10
  import type { LLMMessage, ContentPart } from "./llm-client.ts"
12
11
  import { estimateTokens } from "../utils/toon.ts"
@@ -478,8 +477,9 @@ function scratchpadNoteId(threadId: string, key: string): string {
478
477
  }
479
478
 
480
479
  async function scratchpadCollection() {
481
- const db = await getHiveDb()
482
- return db.collection<ScratchpadDoc>("scratchpad")
480
+ // Vía `col()` y no `db.collection()` a pelo: es lo que aplica el prefijo de
481
+ // tenant y mantiene las notas de cada enjambre en su propia partición.
482
+ return col<ScratchpadDoc>("scratchpad")
483
483
  }
484
484
 
485
485
  export async function saveScratchpadNote(
@@ -14,7 +14,7 @@
14
14
  import { logger } from "../utils/logger.ts"
15
15
  import { col, nextId } from "../storage/hive.ts"
16
16
  import { getHiveDb } from "../storage/hivedb.ts"
17
- import { loadConfig } from "../config/loader.ts"
17
+ import { causalReadsEnabled } from "../storage/causal-events.ts"
18
18
  import type { HiveDB, ToolStats } from "@johpaz/hive-db"
19
19
  import type { TraceDoc, ReflectionDoc, CursorDoc } from "../storage/collections.ts"
20
20
  import { parseThreadId } from "./thread-id.ts"
@@ -70,7 +70,7 @@ export async function runReflector(): Promise<void> {
70
70
  // G9: when enabled, per-tool insights use whole-history stats from HiveDB's
71
71
  // event log (toolStats) instead of counters built from just this batch, and
72
72
  // causal threads add root-cause/learning-proposal insights on top.
73
- const causalDb = loadConfig().causalLog?.enabled ? await getHiveDb() : null
73
+ const causalDb = causalReadsEnabled() ? await getHiveDb() : null
74
74
  const reflectionsCol = await col<ReflectionDoc>("reflections")
75
75
  let totalInsights = 0
76
76
 
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { col, toIndexable, fromIndexable } from "../storage/hive.ts";
19
+ import { qualify } from "../storage/tenant.ts";
19
20
  import { getHiveDb } from "../storage/hivedb.ts";
20
21
  import type { BatchOp } from "@johpaz/hive-db";
21
22
  import type { ModelDoc, AgentDoc } from "../storage/collections.ts";
@@ -161,12 +162,17 @@ export async function renameModel(id: string, nuevoNombre: string): Promise<Mode
161
162
  const afectados = await agentesCol.findBy("model_id", toIndexable(id));
162
163
 
163
164
  const db = await getHiveDb();
165
+ // `batch()` va contra el handle crudo de la base, así que no pasa por `col()`:
166
+ // los nombres de colección hay que resolverlos contra el tenant a mano o el
167
+ // rename escribiría en la partición de otro inquilino.
168
+ const MODELS = qualify("models");
169
+ const AGENTS = qualify("agents");
164
170
  const ops: BatchOp[] = [
165
- { op: "put", collection: "models", id: nuevoId, doc },
166
- ...(nuevoId !== id ? [{ op: "delete" as const, collection: "models", id }] : []),
171
+ { op: "put", collection: MODELS, id: nuevoId, doc },
172
+ ...(nuevoId !== id ? [{ op: "delete" as const, collection: MODELS, id }] : []),
167
173
  ...afectados.map((a) => ({
168
174
  op: "put" as const,
169
- collection: "agents",
175
+ collection: AGENTS,
170
176
  id: a.id,
171
177
  doc: { ...a.doc, model_id: toIndexable(nuevoId), updated_at: Date.now() },
172
178
  expectedVersion: a.version,
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { getHiveDb, getOpenHiveDb } from "./hivedb.ts";
15
15
  import { col } from "./hive.ts";
16
+ import { currentTenant } from "./tenant.ts";
16
17
  import { seedAllData, type SeedOptions } from "./seed.ts";
17
18
  import { ensureSecretsBackend } from "./crypto.ts";
18
19
  import { ensureLegacyThread } from "../agent/thread-store.ts";
@@ -136,6 +137,16 @@ async function ensureSeedData(opts?: SeedOptions): Promise<void> {
136
137
  // tests or long-running processes open a fresh instance in the same runtime.
137
138
  let bootstrappedDb: Awaited<ReturnType<typeof getHiveDb>> | null = null;
138
139
 
140
+ // ...y, dentro de esa instancia, a un tenant concreto. Una sola base compartida
141
+ // por muchos enjambres significa que "ya arrancó" no es una propiedad de la
142
+ // base: el enjambre A puede tener sus 72 índices creados y el B ninguno.
143
+ const bootstrappedTenants = new Set<string>();
144
+
145
+ /** Clave de bootstrap del tenant activo ("_" en modo local/escritorio). */
146
+ function bootstrapScope(): string {
147
+ return currentTenant() ?? "_";
148
+ }
149
+
139
150
  /**
140
151
  * Antes de la separación por canal todos los canales compartían un solo hilo cuyo
141
152
  * `thread_id` era el `userId`. Esa conversación se registra —sin mover un solo
@@ -223,6 +234,16 @@ async function ensureLegacyThreads(): Promise<void> {
223
234
  */
224
235
  export async function ensureHiveDb(opts?: SeedOptions): Promise<void> {
225
236
  const db = await getHiveDb();
237
+ if (db !== bootstrappedDb) bootstrappedTenants.clear();
238
+ const scope = bootstrapScope();
239
+
240
+ // Con varios inquilinos en la misma base, esto se llama una vez por enjambre
241
+ // y por proceso, no una vez por proceso: repetir los ~72 `createIndex` más el
242
+ // seed en cada llamada es exactamente el coste que la consolidación viene a
243
+ // quitar. En modo local (sin tenant) no se cortocircuita nada, para conservar
244
+ // el "reseed en cada arranque" del que dependen los cambios de catálogo.
245
+ if (currentTenant() && bootstrappedDb === db && bootstrappedTenants.has(scope)) return;
246
+
226
247
  await ensureIndexes();
227
248
  // Mint the master key before anything can save an API key, so a fresh
228
249
  // install is durable from the first keystroke rather than after a restart
@@ -239,8 +260,13 @@ export async function ensureHiveDb(opts?: SeedOptions): Promise<void> {
239
260
  if (!existing) await meta.put("schemaVersion", { value: 1 }, { expectedVersion: 0 });
240
261
 
241
262
  bootstrappedDb = db;
263
+ bootstrappedTenants.add(scope);
242
264
  }
243
265
 
244
266
  export function isBootstrapped(): boolean {
245
- return bootstrappedDb !== null && bootstrappedDb === getOpenHiveDb();
267
+ return (
268
+ bootstrappedDb !== null &&
269
+ bootstrappedDb === getOpenHiveDb() &&
270
+ bootstrappedTenants.has(bootstrapScope())
271
+ );
246
272
  }
@@ -7,10 +7,30 @@
7
7
  */
8
8
 
9
9
  import { getHiveDb } from "./hivedb.ts"
10
+ import { currentTenant } from "./tenant.ts"
11
+ import { loadConfig } from "../config/loader.ts"
10
12
  import type { Event, EventPattern } from "@johpaz/hive-db"
11
13
 
12
14
  export type { Event as CausalEvent, EventPattern as CausalEventPattern }
13
15
 
16
+ /**
17
+ * ¿Se pueden hacer lecturas AGREGADAS del log causal en este contexto?
18
+ *
19
+ * `causalThread`, `buildAgentContext` y `toolStats` recorren todos los shards
20
+ * de la base y mezclan sus resultados (`log.rs` — `read_stream_all_agents` y
21
+ * `project::<P>()` para proyecciones con scope Agent). Sobre una base
22
+ * compartida eso devolvería hilos y estadísticas de otros inquilinos. Mientras
23
+ * el motor no exponga las variantes acotadas por agente, con un tenant activo
24
+ * estas lecturas se apagan: perder una señal de reflexión es aceptable, cruzar
25
+ * datos entre agencias no.
26
+ *
27
+ * La ESCRITURA (`append`) no entra aquí: va al shard del propio agente, y los
28
+ * agentes de Hive Cloud ya llevan el enjambre en el id.
29
+ */
30
+ export function causalReadsEnabled(): boolean {
31
+ return !!loadConfig().causalLog?.enabled && !currentTenant()
32
+ }
33
+
14
34
  /**
15
35
  * Live-tail causal events matching `pattern`. Forward-only: only events
16
36
  * appended AFTER this call resolves are delivered. hive-db's subscribe()/
@@ -32,6 +52,16 @@ export type { Event as CausalEvent, EventPattern as CausalEventPattern }
32
52
  export async function watchCausalEvents(
33
53
  pattern: EventPattern
34
54
  ): Promise<AsyncIterable<Event> & { close(): void }> {
55
+ // El log de eventos no tiene colecciones que prefijar: el aislamiento entre
56
+ // inquilinos lo da el shard por `agentId`. Un patrón sin `agentId` sobre una
57
+ // base compartida entregaría los eventos de todos los enjambres, así que se
58
+ // exige explícitamente en vez de filtrar a medias.
59
+ if (currentTenant() && !pattern.agentId) {
60
+ throw new Error(
61
+ "watchCausalEvents: con un tenant activo el patrón debe fijar agentId; " +
62
+ "un tail sin agente cruzaría eventos de otros inquilinos."
63
+ )
64
+ }
35
65
  const db = await getHiveDb()
36
66
  return db.events(pattern)
37
67
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { getHiveDb } from "./hivedb.ts";
11
+ import { qualify } from "./tenant.ts";
11
12
 
12
13
  const MAX_RETRIES = 5;
13
14
 
@@ -32,9 +33,22 @@ export function fromIndexable(value: string | null | undefined): string | null {
32
33
  return value === NO_PARENT || value == null ? null : value;
33
34
  }
34
35
 
36
+ /**
37
+ * Handle a una colección, resuelta contra el tenant activo.
38
+ *
39
+ * `qualify()` es lo que aísla a los inquilinos entre sí en una base compartida
40
+ * (ver storage/tenant.ts). Sin tenant en scope devuelve el nombre pelado, así
41
+ * que el modo local se comporta igual que siempre. Como todo el CRUD del SDK
42
+ * pasa por aquí —incluidos `nextId`, `updateDoc`, `updateManyByIndex`,
43
+ * `findByAny` y `bumpRollup`, más abajo— este es el único punto que hay que
44
+ * mantener honesto.
45
+ *
46
+ * El handle se construye en cada llamada a propósito: cachearlo en una variable
47
+ * de módulo lo dejaría atado al tenant que lo creó primero.
48
+ */
35
49
  export async function col<T>(name: string) {
36
50
  const db = await getHiveDb();
37
- return db.collection<T>(name);
51
+ return db.collection<T>(qualify(name));
38
52
  }
39
53
 
40
54
  /**
@@ -11,6 +11,22 @@
11
11
  export { getHiveDbPath, getHiveDb, closeHiveDb } from "./hivedb.ts";
12
12
  export { ensureHiveDb, isBootstrapped } from "./bootstrap.ts";
13
13
 
14
+ // ─── Aislamiento multi-inquilino ─────────────────────────────────────────────
15
+ // Varios enjambres dentro de una sola HiveDB, prefijando el nombre de colección.
16
+ // Sin tenant en scope todo se comporta como siempre — ver storage/tenant.ts.
17
+ export {
18
+ runInTenant,
19
+ currentTenant,
20
+ requireTenant,
21
+ tenantKeyFromId,
22
+ isTenantKey,
23
+ qualify,
24
+ unqualify,
25
+ qualifyDocId,
26
+ unqualifyDocId,
27
+ scopedFilterValue,
28
+ } from "./tenant.ts";
29
+
14
30
  // ─── Acceso a colecciones ────────────────────────────────────────────────────
15
31
  export {
16
32
  col,
@@ -85,13 +85,19 @@ export async function reconcileOnBoot(bootId: string): Promise<ReconcileResult>
85
85
  log.warn(`[reconcileOnBoot] Failed to repair meetings: ${(err as Error).message}`);
86
86
  }
87
87
 
88
- // 3. agentRuns: any "running" row is orphaned at boot HiveDB is
89
- // single-process and this process just started, so no run can actually be
90
- // executing. Waiting out the lease would leave fast restarts (< lease
91
- // duration) unrepaired forever.
88
+ // 3. agentRuns: a "running" row is orphaned if it belongs to ANOTHER boot.
89
+ //
90
+ // Antes bastaba con mirar el estado: una base por enjambre más un proceso por
91
+ // turno garantizaban que al arrancar no podía haber ninguna corrida viva. Con
92
+ // la base compartida entre inquilinos esa premisa desaparece —este proceso
93
+ // atiende varios enjambres a la vez— y barrer por estado abortaría corridas
94
+ // realmente en vuelo. El `boot_id` de la fila es lo que distingue "muerta en
95
+ // un proceso anterior" de "viva aquí y ahora".
92
96
  try {
93
97
  const agentRunsCol = await col<AgentRunDoc>("agentRuns");
94
- const runningRuns = await agentRunsCol.findBy("status", "running");
98
+ const runningRuns = (await agentRunsCol.findBy("status", "running")).filter(
99
+ (entry) => entry.doc.boot_id !== bootId
100
+ );
95
101
  for (const entry of runningRuns) {
96
102
  const run = entry.doc;
97
103
 
@@ -124,11 +130,13 @@ export async function reconcileOnBoot(bootId: string): Promise<ReconcileResult>
124
130
  log.warn(`[reconcileOnBoot] Failed to repair agentRuns: ${(err as Error).message}`);
125
131
  }
126
132
 
127
- // 4. jobQueue: any "running" row is orphaned at boot (same single-process
128
- // argument as above) → reclaim (pending) or interrupt, ignoring the lease.
133
+ // 4. jobQueue: mismo criterio que arriba sólo las filas de otro boot están
134
+ // realmente huérfanas → reclaim (pending) o interrupt, ignorando el lease.
129
135
  try {
130
136
  const jobsCol = await col<JobDoc>("jobQueue");
131
- const runningJobs = await jobsCol.findBy("status", "running");
137
+ const runningJobs = (await jobsCol.findBy("status", "running")).filter(
138
+ (entry) => entry.doc.boot_id !== bootId
139
+ );
132
140
  for (const entry of runningJobs) {
133
141
  const job = entry.doc;
134
142
  const result_doc = await reclaimOrInterrupt(job.id, { force: true });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Contexto de tenant — aislamiento de varios enjambres dentro de UNA sola HiveDB.
3
+ *
4
+ * Hive Cloud creaba una base física por enjambre porque `getHiveDb()` es un
5
+ * singleton de proceso: para aislar dos enjambres había que aislar dos procesos.
6
+ * Aquí el aislamiento pasa a ser lógico, prefijando el nombre de la colección.
7
+ *
8
+ * El prefijo va en el NOMBRE DE COLECCIÓN y no en el id del documento a
9
+ * propósito. Las colecciones de HiveDB no son tablas separadas: son tres tablas
10
+ * redb con el nombre de colección como primera componente de una clave
11
+ * compuesta (`col_docs:(collection,id)`, `col_index_entries:(collection,field,
12
+ * value,id)`). Prefijar el nombre sale casi gratis y, sobre todo, aísla también
13
+ * los índices secundarios — que es justo lo que un prefijo en el id NO consigue,
14
+ * porque `findBy(field, value)` recorre el índice de la colección entera y
15
+ * devolvería filas de otros tenants.
16
+ *
17
+ * Sin tenant en scope `qualify()` es la identidad, así que la app de escritorio
18
+ * y los tests siguen viendo exactamente los mismos nombres de colección que
19
+ * antes. `HIVE_TENANT_REQUIRED=1` invierte esa tolerancia para los despliegues
20
+ * multi-inquilino: una ruta que se olvide de entrar al contexto falla ruidosa en
21
+ * la primera colección que toque, en vez de escribir en silencio fuera de su
22
+ * partición.
23
+ */
24
+
25
+ import { AsyncLocalStorage } from "node:async_hooks";
26
+
27
+ interface TenantContext {
28
+ key: string;
29
+ }
30
+
31
+ const storage = new AsyncLocalStorage<TenantContext>();
32
+
33
+ /** Separador entre el tenant y el nombre de colección. */
34
+ const SEP = "__";
35
+
36
+ /**
37
+ * `t_` + un uuid sin guiones. Corta a propósito: la clave se repite en cada
38
+ * entrada de `col_docs` y de `col_index_entries`, así que cada carácter de más
39
+ * se paga por documento y por entrada de índice.
40
+ */
41
+ const KEY_PATTERN = /^t_[a-z0-9]{8,48}$/;
42
+
43
+ /**
44
+ * Separador de los filtros escalares compuestos del índice BM25. U+001F (unit
45
+ * separator) porque el motor construye el término como `field<US>value` y ese
46
+ * byte no aparece nunca en un id ni en un tipo real.
47
+ */
48
+ const FILTER_SEP = "\u001f";
49
+
50
+ /** Construye una tenant key válida a partir de un uuid (o cualquier id opaco). */
51
+ export function tenantKeyFromId(id: string): string {
52
+ const normalized = id.toLowerCase().replace(/[^a-z0-9]/g, "");
53
+ if (normalized.length < 8) {
54
+ throw new Error(`tenantKeyFromId: id demasiado corto para ser único: "${id}"`);
55
+ }
56
+ return `t_${normalized.slice(0, 48)}`;
57
+ }
58
+
59
+ /** `true` si la cadena tiene forma de tenant key. */
60
+ export function isTenantKey(value: string): boolean {
61
+ return KEY_PATTERN.test(value);
62
+ }
63
+
64
+ /**
65
+ * Ejecuta `fn` con `key` como tenant activo. Todo lo que `fn` haga por debajo
66
+ * —incluidos los `await` encadenados— ve ese tenant; dos llamadas concurrentes
67
+ * no se pisan porque cada una corre en su propio contexto de AsyncLocalStorage.
68
+ *
69
+ * Ojo: el contexto NO cruza a un `Worker`. Ningún código que corra dentro de un
70
+ * worker puede tocar HiveDB esperando estar aislado.
71
+ */
72
+ export function runInTenant<T>(key: string, fn: () => T): T {
73
+ if (!isTenantKey(key)) {
74
+ throw new Error(`runInTenant: tenant key inválida "${key}" (esperado ${KEY_PATTERN})`);
75
+ }
76
+ return storage.run({ key }, fn);
77
+ }
78
+
79
+ /** Tenant activo, o `null` si no hay ninguno (modo local/escritorio). */
80
+ export function currentTenant(): string | null {
81
+ return storage.getStore()?.key ?? null;
82
+ }
83
+
84
+ /** Tenant activo; lanza si no hay. Para código que sólo tiene sentido aislado. */
85
+ export function requireTenant(context: string): string {
86
+ const key = currentTenant();
87
+ if (!key) {
88
+ throw new Error(`${context} requiere un tenant activo (envolver en runInTenant)`);
89
+ }
90
+ return key;
91
+ }
92
+
93
+ /**
94
+ * Nombre físico de una colección para el tenant activo.
95
+ *
96
+ * Es el único punto por el que pasa el aislamiento documental: `col()` lo llama
97
+ * y con eso quedan cubiertos los ~300 sitios que hacen CRUD, más `nextId`,
98
+ * `updateDoc`, `updateManyByIndex`, `findByAny` y `bumpRollup`.
99
+ */
100
+ export function qualify(collection: string): string {
101
+ const key = currentTenant();
102
+ if (key) return `${key}${SEP}${collection}`;
103
+ if (process.env.HIVE_TENANT_REQUIRED === "1") {
104
+ throw new Error(
105
+ `qualify("${collection}"): HIVE_TENANT_REQUIRED=1 y no hay tenant en scope. ` +
106
+ `Falta envolver esta ruta en runInTenant().`
107
+ );
108
+ }
109
+ return collection;
110
+ }
111
+
112
+ /**
113
+ * Quita el prefijo de tenant de un nombre de colección físico. Devuelve el
114
+ * nombre tal cual si no lo lleva.
115
+ */
116
+ export function unqualify(collection: string): string {
117
+ const match = /^t_[a-z0-9]{8,48}__(.+)$/.exec(collection);
118
+ return match ? match[1] : collection;
119
+ }
120
+
121
+ /**
122
+ * Id de documento para el índice semántico (BM25), que es UNO solo compartido
123
+ * por todos los tenants: ahí el aislamiento va en el id y en los filtros, no en
124
+ * un nombre de colección.
125
+ */
126
+ export function qualifyDocId(id: string): string {
127
+ const key = currentTenant();
128
+ return key ? `${key}:${id}` : id;
129
+ }
130
+
131
+ /** Inversa de {@link qualifyDocId}, para devolver ids limpios en los hits. */
132
+ export function unqualifyDocId(id: string): string {
133
+ const match = /^t_[a-z0-9]{8,48}:(.+)$/.exec(id);
134
+ return match ? match[1] : id;
135
+ }
136
+
137
+ /**
138
+ * Valor de un filtro escalar compuesto para el índice BM25.
139
+ *
140
+ * Hace falta porque `deleteByFilter` acepta UN SOLO filtro: un borrado masivo
141
+ * por `type` en una base compartida se llevaría por delante los documentos de
142
+ * todos los tenants. Con un campo sintético (`tenant__type`) el borrado vuelve a
143
+ * ser de un solo filtro y sigue estando acotado al tenant.
144
+ */
145
+ export function scopedFilterValue(...parts: string[]): string {
146
+ return [currentTenant() ?? "_", ...parts].join(FILTER_SEP);
147
+ }