@johpaz/hive-sdk 0.0.18 → 0.1.3

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 (41) hide show
  1. package/bun.lock +291 -1
  2. package/docs/HIVE-HARNESS.md +113 -0
  3. package/package.json +36 -2
  4. package/packages/cli/package.json +1 -1
  5. package/packages/core/package.json +13 -2
  6. package/packages/core/src/ace/Tracer.ts +1 -1
  7. package/packages/core/src/agent/AgentRunner.ts +12 -0
  8. package/packages/core/src/agent/ContextCompiler.ts +4 -4
  9. package/packages/core/src/agent/ConversationStore.ts +30 -20
  10. package/packages/core/src/agent/selectors/PlaybookSelector.ts +50 -76
  11. package/packages/core/src/agent/selectors/SkillSelector.ts +106 -262
  12. package/packages/core/src/agent/selectors/ToolSelector.ts +53 -89
  13. package/packages/core/src/auth/auth.ts +36 -23
  14. package/packages/core/src/harness/boot-id.ts +20 -0
  15. package/packages/core/src/harness/collections.ts +98 -0
  16. package/packages/core/src/harness/db-helpers.ts +87 -0
  17. package/packages/core/src/harness/durable-queue.ts +337 -0
  18. package/packages/core/src/harness/goal-verifier.ts +141 -0
  19. package/packages/core/src/harness/harness.test.ts +236 -0
  20. package/packages/core/src/harness/index.ts +34 -0
  21. package/packages/core/src/harness/job-store.ts +399 -0
  22. package/packages/core/src/harness/proof-packet.ts +69 -0
  23. package/packages/core/src/harness/reconcile.ts +149 -0
  24. package/packages/core/src/harness/run-epoch.ts +32 -0
  25. package/packages/core/src/harness/run-store.ts +334 -0
  26. package/packages/core/src/index.ts +6 -0
  27. package/packages/core/src/memory/Scratchpad.test.ts +23 -21
  28. package/packages/core/src/memory/Scratchpad.ts +41 -24
  29. package/packages/core/src/storage/HiveDBStorage.ts +64 -0
  30. package/packages/core/src/storage/SQLiteStorage.ts +7 -0
  31. package/packages/core/src/storage/hiveSeed.ts +308 -0
  32. package/packages/core/src/storage/hiveStorage.test.ts +38 -0
  33. package/packages/core/src/storage/index.ts +10 -0
  34. package/packages/core/src/storage/seed.ts +5 -1
  35. package/packages/core/src/storage/usage.ts +106 -167
  36. package/packages/core/src/tool-runtime/tool-runtime.test.ts +11 -3
  37. package/packages/core/src/tools/agents/get-available-models.ts +52 -56
  38. package/packages/core/src/tools/agents/index.ts +77 -60
  39. package/packages/core/src/tools/core/index.ts +106 -291
  40. package/packages/core/src/tools/meeting/index.ts +83 -93
  41. package/packages/core/src/utils/toon.ts +4 -4
@@ -40,8 +40,9 @@
40
40
  * - core (notify, report_progress, save_note)
41
41
  */
42
42
 
43
- import { getDb } from "../../storage/SQLiteStorage.ts"
43
+ import { getHiveDB } from "../../storage/HiveDBStorage.ts"
44
44
  import { logger } from "../../utils/logger.ts"
45
+ import type { HiveDB, IndexDoc } from "@johpaz/hive-db";
45
46
 
46
47
  const log = logger.child("tool-selector")
47
48
 
@@ -302,11 +303,11 @@ function getAbstractionPreference(): "atomic" | "orchestration" {
302
303
  * 5. If ambiguous → prefer atomic over orchestration
303
304
  * 6. Return top maxTools results (default: MAX_TOOLS_PER_TURN)
304
305
  */
305
- export function selectTools(
306
+ export async function selectTools(
306
307
  userMessage: string,
307
308
  fullToolList: ToolDescriptor[] = CORE_TOOL_CATALOG,
308
309
  maxTools: number = MAX_TOOLS_PER_TURN
309
- ): ToolDescriptor[] {
310
+ ): Promise<ToolDescriptor[]> {
310
311
  const startTime = performance.now()
311
312
 
312
313
  // Log incoming user message for debugging/validation
@@ -318,78 +319,58 @@ export function selectTools(
318
319
  return []
319
320
  }
320
321
 
321
- // Step 2: Build FTS5 query
322
- const ftsQuery = buildFTSQuery(userMessage)
323
- if (!ftsQuery) {
324
- log.debug(`[tool-selector] No valid FTS query terms, returning empty array`)
322
+ // Step 2: Build search query
323
+ const searchQuery = buildFTSQuery(userMessage)
324
+ if (!searchQuery) {
325
+ log.debug(`[tool-selector] No valid query terms, returning empty array`)
325
326
  return []
326
327
  }
327
328
 
328
- log.debug(`[tool-selector] FTS query: "${ftsQuery}"`)
329
-
330
- // Step 3: Execute FTS5 query with bm25 scoring
331
- const db = getDb()
332
-
333
- // Use bm25() with column weights for relevance scoring
334
- // FTS5 table columns: tool_name, name, description, category
335
- // Weights: tool_name=1.0, name=5.0, description=3.0, category=1.0
336
- // Higher weight on name (5.0) for exact tool name matching
337
- // Get more initially (maxTools * 2) for filtering, then limit to maxTools
338
- const ftsResults = db.query(`
339
- SELECT tool_name, bm25(tools_fts, 1.0, 5.0, 3.0, 1.0) as bm25_score
340
- FROM tools_fts
341
- WHERE tools_fts MATCH ?
342
- ORDER BY bm25_score ASC
343
- LIMIT ?
344
- `).all(ftsQuery, maxTools * 2) as { tool_name: string; bm25_score: number }[]
345
-
346
- if (ftsResults.length === 0) {
347
- log.debug(`[tool-selector] No FTS matches, returning empty array`)
348
- return []
349
- }
329
+ log.debug(`[tool-selector] Search query: "${searchQuery}"`)
350
330
 
351
- // Log raw scores for debugging
352
- log.info(`[tool-selector] Raw FTS scores: ${ftsResults.slice(0, 10).map(r => `${r.tool_name}=${r.bm25_score.toFixed(2)}`).join(", ")}`)
331
+ // Step 3: Execute hybrid search over the HiveDB semantic index
332
+ const db = await getHiveDB()
353
333
 
354
- // Step 4: Apply relevance threshold filter
355
- // bm25() returns negative scores; threshold is -0.5 (loosened from typical -5)
356
- const relevantResults = ftsResults.filter(r => r.bm25_score >= MIN_RELEVANCE_THRESHOLD)
334
+ // HiveDB text-only BM25 returns positive scores where higher is better.
335
+ const hits = await db.queryHybrid({
336
+ text: searchQuery,
337
+ k: maxTools * 2,
338
+ boosts: { name: 5.0, body: 3.0, tags: 1.0 },
339
+ })
357
340
 
358
- if (relevantResults.length === 0) {
359
- log.debug(`[tool-selector] All results below threshold ${MIN_RELEVANCE_THRESHOLD}, returning empty`)
341
+ if (hits.length === 0) {
342
+ log.debug(`[tool-selector] No index matches, returning empty array`)
360
343
  return []
361
344
  }
362
345
 
363
- // Step 5: Map to tool descriptors with additional metadata
346
+ // Log raw scores for debugging
347
+ log.info(`[tool-selector] Raw scores: ${hits.slice(0, 10).map(r => `${r.id}=${r.score.toFixed(2)}`).join(", ")}`)
348
+
349
+ // Step 4: Map to tool descriptors with additional metadata
364
350
  const toolMap = new Map(fullToolList.map(t => [t.name, t]))
365
351
 
366
352
  const scoredTools: SelectedTool[] = []
367
353
 
368
- for (const result of relevantResults) {
369
- const tool = toolMap.get(result.tool_name)
354
+ for (const hit of hits) {
355
+ const tool = toolMap.get(hit.id)
370
356
  if (tool) {
371
357
  scoredTools.push({
372
358
  name: tool.name,
373
- score: result.bm25_score,
359
+ score: hit.score,
374
360
  category: tool.category,
375
361
  })
376
362
  }
377
363
  }
378
364
 
379
- // Step 6: Prefer atomic over orchestration when ambiguous
380
- // If we have more than MAX_TOOLS_PER_TURN, prioritize by abstraction level
365
+ // Step 5: Prefer atomic over orchestration when ambiguous
381
366
  const abstractionPref = getAbstractionPreference()
382
367
 
383
368
  if (scoredTools.length > MAX_TOOLS_PER_TURN) {
384
- // Sort by score first, then by abstraction level preference
385
- // CRITICAL FIX: bm25() returns NEGATIVE scores where closer to 0 = more relevant
386
- // So we sort ASCENDING (a.score - b.score) to put -8.02 before -5.11
369
+ // Sort by score descending (higher HiveDB score = more relevant)
387
370
  scoredTools.sort((a, b) => {
388
- // First by score (ascending for bm25 - closer to 0 is better)
389
371
  if (Math.abs(a.score - b.score) > 0.1) {
390
- return a.score - b.score // ✅ Fixed: ascending for negative bm25 scores
372
+ return b.score - a.score
391
373
  }
392
- // Then by abstraction preference (preferred type first)
393
374
  const aTool = toolMap.get(a.name)
394
375
  const bTool = toolMap.get(b.name)
395
376
  const aLevel = aTool?.abstractionLevel ?? "atomic"
@@ -403,15 +384,14 @@ export function selectTools(
403
384
  })
404
385
  }
405
386
 
406
- // Step 7: Take top N tools
387
+ // Step 6: Take top N tools
407
388
  const topTools = scoredTools.slice(0, maxTools)
408
389
 
409
- // Step 8: Return as ToolDescriptor array
390
+ // Step 7: Return as ToolDescriptor array
410
391
  const result = topTools.map(t => toolMap.get(t.name)!).filter(Boolean)
411
392
 
412
393
  const timing = performance.now() - startTime
413
394
 
414
- // Log final selected tools with info level (important for tracking tool selection process)
415
395
  if (result.length > 0) {
416
396
  log.info(`[tool-selector] Selected ${result.length} tools in ${timing.toFixed(2)}ms:`,
417
397
  result.map(t => ({ name: t.name, category: t.category })))
@@ -434,29 +414,29 @@ export function selectTools(
434
414
  * @param tools - Optional array of tools to sync. If not provided, fetches from DB.
435
415
  */
436
416
  export async function syncToolCatalogToFTS(tools?: ToolDescriptor[]): Promise<void> {
437
- const db = getDb()
417
+ const db = await getHiveDB()
438
418
 
439
419
  try {
440
- // Step 1: Build full catalog = CORE_TOOL_CATALOG + any tools in DB not already covered
441
- // CORE_TOOL_CATALOG has bilingual keywords; DB tools may be dynamically registered
420
+ // Step 1: Build full catalog = CORE_TOOL_CATALOG + any explicitly passed tools
442
421
  const catalogByName = new Map<string, ToolDescriptor>(
443
422
  CORE_TOOL_CATALOG.map(t => [t.name, t])
444
423
  )
445
424
 
446
- // Merge in any tools from the DB that are missing from the static catalog
447
- const dbTools = db.query("SELECT name, description, category FROM tools").all() as Array<{ name: string; description: string | null; category: string | null }>
448
- for (const row of dbTools) {
425
+ // Merge in any tools persisted in HiveDB that are missing from the static catalog
426
+ const toolsCol = db.collection<ToolDescriptor>("tools")
427
+ const dbTools = await toolsCol.scan()
428
+ for (const entry of dbTools) {
429
+ const row = entry.doc
449
430
  if (!catalogByName.has(row.name)) {
450
431
  catalogByName.set(row.name, {
451
432
  name: row.name,
452
433
  description: row.description ?? row.name,
453
- category: (row.category ?? "core") as any,
454
- abstractionLevel: "atomic",
434
+ category: row.category ?? "core",
435
+ abstractionLevel: row.abstractionLevel ?? "atomic",
455
436
  })
456
437
  }
457
438
  }
458
439
 
459
- // Also merge any explicitly passed tools (e.g. from initializer)
460
440
  for (const t of (tools || [])) {
461
441
  if (!catalogByName.has(t.name)) {
462
442
  catalogByName.set(t.name, t)
@@ -465,39 +445,23 @@ export async function syncToolCatalogToFTS(tools?: ToolDescriptor[]): Promise<vo
465
445
 
466
446
  const toolCatalog = Array.from(catalogByName.values())
467
447
 
468
- // Step 2: Atomic transaction for FTS5 sync
469
- // We use a transaction to ensure that if sync fails, we don't end up with an empty FTS table
470
- const syncTransaction = db.transaction(() => {
471
- // Verify table exists inside transaction (optional but safer)
472
- const tableCheck = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='tools_fts'").get()
473
- if (!tableCheck) {
474
- throw new Error("tools_fts table does not exist!")
475
- }
476
-
477
- // A: Clear existing data
478
- db.run("DELETE FROM tools_fts")
479
-
480
- // B: Prepare insertion
481
- const insert = db.prepare(`
482
- INSERT INTO tools_fts(tool_name, name, description, category)
483
- VALUES (?, ?, ?, ?)
484
- `)
485
-
486
- // C: Re-populate
487
- for (const tool of toolCatalog) {
488
- const enriched = enrichToolDescription(tool)
489
- insert.run(tool.name, tool.name, enriched, tool.category)
490
- }
491
- })
448
+ // Step 2: Build index documents and upsert atomically via batch
449
+ const docs: IndexDoc[] = toolCatalog.map(tool => ({
450
+ id: tool.name,
451
+ name: tool.name,
452
+ body: enrichToolDescription(tool),
453
+ tags: tool.category,
454
+ filters: [{ field: "type", value: "tool" }],
455
+ }))
492
456
 
493
- // Execute transaction
494
- syncTransaction()
457
+ await db.clearIndex()
458
+ await db.upsertBatch(docs)
495
459
 
496
- log.info(`[tool-selector] Atomic sync complete: ${toolCatalog.length} tools indexed in FTS5`)
460
+ log.info(`[tool-selector] Atomic sync complete: ${toolCatalog.length} tools indexed in HiveDB`)
497
461
 
498
462
  } catch (err) {
499
463
  log.error(`[tool-selector] Transactional sync failed:`, err)
500
- throw err // Re-throw to inform initializer
464
+ throw err
501
465
  }
502
466
  }
503
467
 
@@ -506,7 +470,7 @@ export async function syncToolCatalogToFTS(tools?: ToolDescriptor[]): Promise<vo
506
470
  *
507
471
  * This improves FTS5 matching for both English and Spanish queries.
508
472
  */
509
- function enrichToolDescription(tool: ToolDescriptor): string {
473
+ export function enrichToolDescription(tool: ToolDescriptor): string {
510
474
  const keywordsByCategory: Record<string, string> = {
511
475
  scheduling: "programar recordatorio alarma cron schedule reminder task future tiempo",
512
476
  projects: "proyecto tarea plan organizer milestone backlog sprint work",
@@ -1,6 +1,6 @@
1
1
  import jwt from "jsonwebtoken";
2
2
  import { hashString } from "../utils/crypto.ts";
3
- import { getDb } from "../storage/SQLiteStorage.ts";
3
+ import { getHiveDB } from "../storage/HiveDBStorage.ts";
4
4
 
5
5
  const JWT_SECRET = process.env.JWT_SECRET || "hive-default-jwt-secret-change-in-production";
6
6
  const ACCESS_TOKEN_EXPIRY = "15m";
@@ -19,6 +19,17 @@ interface JwtPayload {
19
19
  type: "access" | "refresh";
20
20
  }
21
21
 
22
+ interface RefreshTokenDoc {
23
+ userId: string;
24
+ tokenHash: string;
25
+ expiresAt: number;
26
+ revoked: boolean;
27
+ }
28
+
29
+ function tokensCollection() {
30
+ return getHiveDB().then(db => db.collection<RefreshTokenDoc>("refresh_tokens"));
31
+ }
32
+
22
33
  export async function generateTokens(userId: string): Promise<AuthTokens> {
23
34
  const accessToken = jwt.sign({ userId, type: "access" } satisfies JwtPayload, JWT_SECRET, {
24
35
  expiresIn: ACCESS_TOKEN_EXPIRY,
@@ -31,12 +42,8 @@ export async function generateTokens(userId: string): Promise<AuthTokens> {
31
42
  const refreshTokenHash = hashString(refreshToken);
32
43
  const expiresAt = Math.floor(Date.now() / 1000) + REFRESH_TOKEN_EXPIRY_SECONDS;
33
44
 
34
- const db = getDb();
35
- db.run(
36
- `INSERT INTO refresh_tokens (user_id, token_hash, expires_at, revoked)
37
- VALUES (?, ?, ?, 0)`,
38
- [userId, refreshTokenHash, expiresAt]
39
- );
45
+ const col = await tokensCollection();
46
+ await col.put(refreshTokenHash, { userId, tokenHash: refreshTokenHash, expiresAt, revoked: false });
40
47
 
41
48
  return {
42
49
  accessToken,
@@ -59,27 +66,25 @@ export async function refreshAccessToken(refreshToken: string): Promise<AuthToke
59
66
  }
60
67
 
61
68
  const refreshTokenHash = hashString(refreshToken);
62
- const db = getDb();
63
- const tokenRow = db
64
- .query(
65
- `SELECT user_id, expires_at, revoked FROM refresh_tokens WHERE token_hash = ?`
66
- )
67
- .get(refreshTokenHash) as { user_id: string; expires_at: number; revoked: number } | undefined;
68
-
69
- if (!tokenRow) {
69
+ const col = await tokensCollection();
70
+ const entry = await col.get(refreshTokenHash);
71
+
72
+ if (!entry) {
70
73
  throw new Error("Refresh token not found");
71
74
  }
72
75
 
73
- if (tokenRow.revoked === 1) {
76
+ const tokenRow = entry.doc;
77
+
78
+ if (tokenRow.revoked) {
74
79
  throw new Error("Refresh token has been revoked");
75
80
  }
76
81
 
77
- if (tokenRow.expires_at < Math.floor(Date.now() / 1000)) {
78
- db.run(`DELETE FROM refresh_tokens WHERE token_hash = ?`, [refreshTokenHash]);
82
+ if (tokenRow.expiresAt < Math.floor(Date.now() / 1000)) {
83
+ await col.delete(refreshTokenHash);
79
84
  throw new Error("Refresh token has expired");
80
85
  }
81
86
 
82
- db.run(`DELETE FROM refresh_tokens WHERE token_hash = ?`, [refreshTokenHash]);
87
+ await col.delete(refreshTokenHash);
83
88
 
84
89
  return generateTokens(payload.userId);
85
90
  }
@@ -98,11 +103,19 @@ export async function validateAccessToken(token: string): Promise<{ userId: stri
98
103
 
99
104
  export async function revokeRefreshToken(refreshToken: string): Promise<void> {
100
105
  const refreshTokenHash = hashString(refreshToken);
101
- const db = getDb();
102
- db.run(`UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?`, [refreshTokenHash]);
106
+ const col = await tokensCollection();
107
+ const entry = await col.get(refreshTokenHash);
108
+ if (entry) {
109
+ await col.put(refreshTokenHash, { ...entry.doc, revoked: true });
110
+ }
103
111
  }
104
112
 
105
113
  export async function revokeAllUserTokens(userId: string): Promise<void> {
106
- const db = getDb();
107
- db.run(`UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?`, [userId]);
114
+ const col = await tokensCollection();
115
+ const entries = await col.scan();
116
+ for (const e of entries) {
117
+ if (e.doc.userId === userId && !e.doc.revoked) {
118
+ await col.put(e.id, { ...e.doc, revoked: true });
119
+ }
120
+ }
108
121
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * boot-id — a unique id generated on every process start so that durable
3
+ * leases (harness runs / jobs) can detect which rows belong to a dead
4
+ * process after a crash/restart.
5
+ */
6
+
7
+ import { randomBytes } from "node:crypto";
8
+
9
+ let currentBootId: string | null = null;
10
+
11
+ export function getBootId(): string {
12
+ if (!currentBootId) {
13
+ currentBootId = randomBytes(8).toString("hex");
14
+ }
15
+ return currentBootId;
16
+ }
17
+
18
+ export function resetBootId(): void {
19
+ currentBootId = null;
20
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Document shapes for the harness's HiveDB collections. Ported from `hive`'s
3
+ * durable-task harness, generalized for SDK consumers: `JobDoc.type` and
4
+ * `HarnessRunDoc.kind` are plain strings (not a fixed union) so a host app
5
+ * (hive-cloud, a custom hive-sdk app, etc.) can define its own job/run
6
+ * vocabulary and register executors for it via `registerExecutor()`.
7
+ */
8
+
9
+ export interface HarnessRunDoc {
10
+ id: string
11
+ thread_id: string
12
+ agent_id: string
13
+ user_id: string
14
+ channel: string | null
15
+ /** Host-defined run kind, e.g. "chat" | "worker" | "goal". */
16
+ kind: string
17
+ status: "running" | "completed" | "failed" | "interrupted" | "aborted"
18
+
19
+ iterations_used: number
20
+ max_iterations: number
21
+ turns_used: number
22
+ max_turns: number | null
23
+ tokens_used: number
24
+ max_tokens: number | null
25
+
26
+ goal: string | null
27
+ goal_check_tool: string | null
28
+ goal_attempts: number
29
+
30
+ state_json: string
31
+ state_bytes: number
32
+ pending_tool_calls_json: string | null
33
+ checkpointed_at: number
34
+
35
+ boot_id: string
36
+ lease_expires_at: number
37
+ resume_policy: "resume" | "mark_interrupted" | "discard"
38
+
39
+ /** Whole-job acceptance criteria (harness-engineering "proof" concept): JSON array of AcceptanceCriterion. */
40
+ acceptance_json: string | null
41
+ /** Fixed-worker epoch recorded at run creation: RunEpoch JSON. */
42
+ epoch_json: string | null
43
+
44
+ error: string | null
45
+ created_at: number
46
+ updated_at: number
47
+ finished_at: number | null
48
+ }
49
+
50
+ export interface JobDoc {
51
+ id: string
52
+ lane: string
53
+ /** Host-defined job type, e.g. "chat_turn" | "worker_task" | "goal_run". */
54
+ type: string
55
+ status: "pending" | "running" | "completed" | "failed" | "cancelled" | "interrupted"
56
+ priority: number
57
+ payload_json: string
58
+ run_id: string
59
+ attempts: number
60
+ max_attempts: number
61
+ not_before: number
62
+ boot_id: string | null
63
+ lease_expires_at: number | null
64
+ result_json: string | null
65
+ error: string | null
66
+ created_at: number
67
+ started_at: number | null
68
+ finished_at: number | null
69
+ /** Logical-failure retries (executor returned {ok:false, retryable:true}). Separate from `attempts` (crash/lease-expiry only). */
70
+ retry_count: number
71
+ /** Error from the most recent logical-failure retry; `error` stays null until the job is terminal. */
72
+ last_error: string | null
73
+ /** `toIndexable`-encoded — sentinel when unset. Client-supplied dedup key for job creation. */
74
+ idempotency_key: string
75
+ }
76
+
77
+ /**
78
+ * Compressed evidence artifact for a completed run — the "proof packet"
79
+ * concept from harness-engineering's proof/verification practice: what was
80
+ * intended, what was checked, what evidence backs the verdict, known limits.
81
+ */
82
+ export interface ProofPacketDoc {
83
+ id: string
84
+ run_id: string
85
+ agent_id: string
86
+ intended_outcome: string
87
+ /** Per-acceptance-criterion verdicts: [{id, description, met, evidence}]. */
88
+ acceptance_results_json: string
89
+ /** Names of checks executed (tool ids, LLM verifier, etc). */
90
+ checks_run_json: string
91
+ /** Free-form evidence snippets backing the verdict (tool outputs, verifier reasons). */
92
+ evidence_json: string
93
+ known_limits: string | null
94
+ /** Fixed-worker epoch this run executed under — copied from HarnessRunDoc.epoch_json. */
95
+ epoch_json: string | null
96
+ met: boolean
97
+ created_at: number
98
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Reusable HiveDB collection helpers for the harness module — ported from
3
+ * `hive`'s `storage/hive.ts`. Provides the primitives HiveDB's `Collection`
4
+ * API doesn't have directly: autoincrement ids (`nextId`), whitelisted
5
+ * partial UPDATEs (`updateDoc`), and `WHERE field IN (...)` (`findByAny`).
6
+ */
7
+
8
+ import { hiveCollection } from "../storage/HiveDBStorage.ts";
9
+
10
+ const MAX_RETRIES = 5;
11
+
12
+ /** Sentinel for nullable FK-like fields used in equality indexes (`findBy`/`createIndex` reject `null`). */
13
+ export const NO_PARENT = "__none__";
14
+
15
+ /** Encode a nullable FK-like value for storage in an indexed field. */
16
+ export function toIndexable(value: string | null | undefined): string {
17
+ return value ?? NO_PARENT;
18
+ }
19
+
20
+ /** Decode a value stored via {@link toIndexable} back to its nullable form. */
21
+ export function fromIndexable(value: string): string | null {
22
+ return value === NO_PARENT ? null : value;
23
+ }
24
+
25
+ export async function col<T>(name: string) {
26
+ return hiveCollection<T>(name);
27
+ }
28
+
29
+ /**
30
+ * Monotonic counter, formatted as a zero-padded string so lexicographic
31
+ * `scan()` order matches numeric order. Retries on optimistic-concurrency
32
+ * conflicts (another writer bumped the same counter concurrently).
33
+ */
34
+ export async function nextId(counterName: string): Promise<string> {
35
+ const counters = await col<{ value: number }>("harness_counters");
36
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
37
+ const cur = await counters.get(counterName);
38
+ const next = (cur?.doc.value ?? 0) + 1;
39
+ try {
40
+ await counters.put(counterName, { value: next }, { expectedVersion: cur?.version ?? 0 });
41
+ return String(next).padStart(15, "0");
42
+ } catch {
43
+ // Version conflict — another writer won the race, retry.
44
+ }
45
+ }
46
+ throw new Error(`nextId: too much contention on counter "${counterName}"`);
47
+ }
48
+
49
+ /**
50
+ * Read-modify-write a document, replacing the whole-document `put()` with
51
+ * whitelisted-field UPDATE semantics. Retries on optimistic-concurrency
52
+ * conflicts.
53
+ */
54
+ export async function updateDoc<T extends object>(
55
+ collection: string,
56
+ id: string,
57
+ patch: Partial<T>
58
+ ): Promise<T> {
59
+ const c = await col<T>(collection);
60
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
61
+ const existing = await c.get(id);
62
+ if (!existing) throw new Error(`${collection}/${id} not found`);
63
+ const merged = { ...existing.doc, ...patch };
64
+ try {
65
+ await c.put(id, merged, { expectedVersion: existing.version });
66
+ return merged;
67
+ } catch {
68
+ // Version conflict — retry with a fresh read.
69
+ }
70
+ }
71
+ throw new Error(`updateDoc: too much contention on ${collection}/${id}`);
72
+ }
73
+
74
+ /**
75
+ * Fetch documents whose indexed `field` matches any of `values` — emulates
76
+ * `WHERE field IN (...)`. Requires a prior `createIndex(field)`.
77
+ */
78
+ export async function findByAny<T>(
79
+ collection: string,
80
+ field: string,
81
+ values: Array<string | number | boolean>
82
+ ): Promise<Array<{ id: string; version: number; doc: T }>> {
83
+ const c = await col<T>(collection);
84
+ const uniq = [...new Set(values)];
85
+ const chunks = await Promise.all(uniq.map((v) => c.findBy(field, v)));
86
+ return chunks.flat();
87
+ }