or3-provider-sqlite 0.0.13 → 0.0.15

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
@@ -227,6 +227,13 @@ bun run type-check # TypeScript validation
227
227
  bun run build # build for distribution
228
228
  ```
229
229
 
230
+ The normal test suite runs in a provider-only checkout using hash-verified,
231
+ byte-for-byte host runtime fixtures. `bun run test:host-integration` reruns the
232
+ same background-job, canonical-history and registration cases against a prepared
233
+ sibling `../or3-chat` at the exact reviewed source tree. It fails on missing or
234
+ mismatched source rather than falling back to fixtures. See the
235
+ [fixture provenance and prerequisites](test/fixtures/host/README.md).
236
+
230
237
  ## Compatibility
231
238
 
232
239
  - Works with multiple auth providers (`basic-auth`, `clerk`, or custom)
@@ -274,8 +281,20 @@ or failed builds fall back to installed packages with a warning.
274
281
  `OR3_LOCAL_PROVIDERS=false` disables local selection. Production builds use the
275
282
  installed package, so local development does not publish these changes.
276
283
 
284
+ ### Canonical compaction history reader (unreleased)
285
+
286
+ `canonicalChatHistory: 'v1'` and `readChatHistory` read current `s_threads`/`s_messages`, with explicit workspace membership (owner/editor/viewer), bounded by-ID queries and canonical `(index, order_key, id)` keyset pages. Migration 022 adds transactional per-thread revision triggers. Migration 023 matches the index to legacy missing-order-key normalization and preserves the existing orphan-row cleanup lifecycle; revision bookkeeping does not add a foreign key to otherwise independent sync rows. Current-chat tool result writes do not invalidate ancestor search cursors. The reader never consults retained sync logs. D1 omits the capability.
287
+
288
+ The additive local qualification command is `bun run build` followed by `bunx vitest run --config vitest.canonical-history.config.ts`. It checks committed current host contract bytes without replacing the older pinned fixture. The current-host lane passes 119 cases across three files, including the real gateway owner and source-built artifact reads after change-log deletion, legacy tie paging in both directions, bounded queries, revision changes, cancellation, revoked membership and an index plan without a temporary sort. These checks do not qualify a deployed rollout, second-client/background reconciliation or released pins. This compaction capability is not yet included in a published package.
289
+
290
+ The canonical-history qualification lane defaults to a sibling `../or3-chat` checkout. For an isolated host worktree, set `OR3_CANONICAL_HOST_ROOT=/absolute/path/to/or3-chat` before the command. Install the host dependencies too; the two-client fixture resolves its IndexedDB test dependency from that checkout. Its contract-byte checks fail closed on uncommitted changes; this does not replace the pinned host fixture or certify deployed/released provider behavior.
291
+
277
292
  ## Workspace Files and Trash admission
278
293
 
294
+ Persistent Projects uses this same capability for opaque `or3:project-settings`, `or3:project-memory`, and `or3:project-source` posts. Preserve their JSON, metadata, and every original/extraction `file_hashes` reference. The project-aware canonical reader resolves `threads.project_id` or a single legacy membership in its authorized SQLite snapshot, returning `project_ownership: resolved | conflict`. String entries and JSON-encoded membership arrays remain supported; malformed or ambiguous memberships do not grant server execution. Hosts without this result must refuse project-sensitive server admission rather than infer ownership from the visible UI.
295
+
296
+ Version 0.0.15 declares `projectOwnership: 'v1'` on the native canonical-history adapter. D1 does not advertise this contract. Projects qualification: `OR3_PROJECT_HOST_ROOT=/absolute/path/to/or3-chat bun x vitest run src/runtime/__tests__/sqlite-sync-gateway-adapter.test.ts`. This explicit lane uses the host's canonical-reader query contract while retaining the pinned standalone fixture for existing contracts. Qualify the published exact version with the disposable host cloud journey.
297
+
279
298
  Native synchronous adapters advertise workspace-item capability v1. Canonical
280
299
  posts/projects with catalog, logical Trash, or file-membership semantics require
281
300
  a current writer even when the incoming update omits those fields. Admission is
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "or3-provider-sqlite",
3
3
  "configKey": "or3-provider-sqlite",
4
- "version": "0.0.13",
4
+ "version": "0.0.15",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -1,4 +1,5 @@
1
1
  import { getJobConfig } from "~~/server/utils/background-jobs/store";
2
+ import { readRequestUsage } from "~~/shared/chat/compaction";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { d1All, d1Run } from "../db/d1.js";
4
5
  import { getRawDb, isD1Driver } from "../db/kysely.js";
@@ -9,6 +10,13 @@ function parseJson(value) {
9
10
  function json(value) {
10
11
  return value === void 0 ? null : JSON.stringify(value);
11
12
  }
13
+ function readStoredUsage(value) {
14
+ try {
15
+ return readRequestUsage(parseJson(value));
16
+ } catch {
17
+ return void 0;
18
+ }
19
+ }
12
20
  function toJob(row) {
13
21
  return {
14
22
  id: row.id,
@@ -29,6 +37,7 @@ function toJob(row) {
29
37
  completedAt: row.completed_at ?? void 0,
30
38
  error: row.error ?? void 0,
31
39
  tool_calls: parseJson(row.tool_calls_json),
40
+ usage: readStoredUsage(row.usage_json),
32
41
  workflow_state: parseJson(
33
42
  row.workflow_state_json
34
43
  ),
@@ -326,12 +335,19 @@ export class SqliteBackgroundJobProvider {
326
335
  sets.push("workflow_state_json = ?");
327
336
  values.push(json(update.workflow_state));
328
337
  }
338
+ const usage = readRequestUsage(update.usage);
339
+ if (usage) {
340
+ sets.push("usage_json = ?");
341
+ values.push(json(usage));
342
+ }
329
343
  values.push(jobId);
330
344
  let where = "id = ? AND status = 'streaming'";
331
345
  if (update.leaseOwner) {
332
346
  where += " AND lease_owner = ? AND lease_expires_at > ?";
333
347
  values.push(update.leaseOwner);
334
348
  values.push(Date.now());
349
+ } else {
350
+ where += " AND lease_owner IS NULL";
335
351
  }
336
352
  const changes = await run(
337
353
  `UPDATE background_jobs SET ${sets.join(", ")} WHERE ${where}`,
@@ -353,8 +369,10 @@ export class SqliteBackgroundJobProvider {
353
369
  values.push(jobId);
354
370
  let where = "id = ? AND status = 'streaming'";
355
371
  if (leaseOwner) {
356
- where += " AND lease_owner = ?";
357
- values.push(leaseOwner);
372
+ where += " AND lease_owner = ? AND lease_expires_at > ?";
373
+ values.push(leaseOwner, now);
374
+ } else {
375
+ where += " AND lease_owner IS NULL";
358
376
  }
359
377
  const changes = await run(
360
378
  `UPDATE background_jobs SET status = ?, completed_at = ?, last_activity_at = ?,
@@ -403,6 +421,11 @@ export class SqliteBackgroundJobProvider {
403
421
  reasoning = CASE WHEN attempts > 0
404
422
  THEN COALESCE(json_extract(execution_json, '$.reasoningBase'), '')
405
423
  ELSE reasoning END,
424
+ usage_json = CASE WHEN attempts > 0
425
+ THEN CASE WHEN json_type(execution_json, '$.normalizedToolState.requestUsage') = 'object'
426
+ THEN json_extract(execution_json, '$.normalizedToolState.requestUsage')
427
+ ELSE NULL END
428
+ ELSE usage_json END,
406
429
  chunks_received = CASE WHEN attempts > 0 THEN 0 ELSE chunks_received END,
407
430
  attempts = attempts + 1
408
431
  WHERE id = ? AND status = 'streaming' AND execution_json IS NOT NULL
@@ -430,6 +453,11 @@ export class SqliteBackgroundJobProvider {
430
453
  reasoning = CASE WHEN attempts > 0
431
454
  THEN COALESCE(json_extract(execution_json, '$.reasoningBase'), '')
432
455
  ELSE reasoning END,
456
+ usage_json = CASE WHEN attempts > 0
457
+ THEN CASE WHEN json_type(execution_json, '$.normalizedToolState.requestUsage') = 'object'
458
+ THEN json_extract(execution_json, '$.normalizedToolState.requestUsage')
459
+ ELSE NULL END
460
+ ELSE usage_json END,
433
461
  chunks_received = CASE WHEN attempts > 0 THEN 0 ELSE chunks_received END,
434
462
  attempts = attempts + 1
435
463
  WHERE id = (
@@ -462,11 +490,13 @@ export class SqliteBackgroundJobProvider {
462
490
  async updateJobExecution(jobId, execution, leaseOwner) {
463
491
  return await run(
464
492
  `UPDATE background_jobs SET execution_json = ?, last_activity_at = ?
465
- WHERE id = ? AND status = 'streaming' AND lease_owner = ?`,
493
+ WHERE id = ? AND status = 'streaming' AND lease_owner = ?
494
+ AND lease_expires_at > ?`,
466
495
  json(execution),
467
496
  Date.now(),
468
497
  jobId,
469
- leaseOwner
498
+ leaseOwner,
499
+ Date.now()
470
500
  ) > 0;
471
501
  }
472
502
  async claimClientToolCall(jobId, userId, callId, claimToken, claimExpiresAt) {
@@ -521,6 +551,7 @@ export class SqliteBackgroundJobProvider {
521
551
  snapshot.content,
522
552
  snapshot.reasoning,
523
553
  json(snapshot.toolCalls),
554
+ json(readRequestUsage(snapshot.usage)),
524
555
  snapshot.error ?? null,
525
556
  snapshot.completedAt,
526
557
  snapshot.completedAt
@@ -530,10 +561,12 @@ export class SqliteBackgroundJobProvider {
530
561
  if (leaseOwner) {
531
562
  where += " AND lease_owner = ? AND lease_expires_at > ?";
532
563
  values.push(leaseOwner, Date.now());
564
+ } else {
565
+ where += " AND lease_owner IS NULL";
533
566
  }
534
567
  return await run(
535
568
  `UPDATE background_jobs SET status = ?, content = ?, reasoning = ?,
536
- tool_calls_json = ?, error = ?, completed_at = ?,
569
+ tool_calls_json = ?, usage_json = COALESCE(?, usage_json), error = ?, completed_at = ?,
537
570
  history_phase = 'finalization_pending',
538
571
  last_activity_at = ?, lease_owner = NULL, lease_expires_at = NULL
539
572
  WHERE ${where}`,
@@ -19,6 +19,9 @@ import * as m017 from "./migrations/017_background_jobs.js";
19
19
  import * as m018 from "./migrations/018_background_admission_cancels.js";
20
20
  import * as m019 from "./migrations/019_background_generation_history.js";
21
21
  import * as m020 from "./migrations/020_background_generation_receipts.js";
22
+ import * as m021 from "./migrations/021_background_request_usage.js";
23
+ import * as m022 from "./migrations/022_canonical_chat_reader.js";
24
+ import * as m023 from "./migrations/023_legacy_chat_seek_index.js";
22
25
  const REQUIRED_SCHEMA_TABLES = [
23
26
  "users",
24
27
  "auth_accounts",
@@ -68,7 +71,10 @@ const migrations = {
68
71
  "017_background_jobs": m017,
69
72
  "018_background_admission_cancels": m018,
70
73
  "019_background_generation_history": m019,
71
- "020_background_generation_receipts": m020
74
+ "020_background_generation_receipts": m020,
75
+ "021_background_request_usage": m021,
76
+ "022_canonical_chat_reader": m022,
77
+ "023_legacy_chat_seek_index": m023
72
78
  };
73
79
  class StaticMigrationProvider {
74
80
  async getMigrations() {
@@ -0,0 +1,4 @@
1
+ import { type Kysely } from 'kysely';
2
+ /** Optional last measured request; existing jobs remain unmeasured. */
3
+ export declare function up(db: Kysely<unknown>): Promise<void>;
4
+ export declare function down(db: Kysely<unknown>): Promise<void>;
@@ -0,0 +1,7 @@
1
+ import { sql } from "kysely";
2
+ export async function up(db) {
3
+ await sql`ALTER TABLE background_jobs ADD COLUMN usage_json TEXT`.execute(db);
4
+ }
5
+ export async function down(db) {
6
+ await sql`ALTER TABLE background_jobs DROP COLUMN usage_json`.execute(db);
7
+ }
@@ -0,0 +1,4 @@
1
+ import { type Kysely } from 'kysely';
2
+ /** Workspace-scoped canonical message keysets, independent of retained logs. */
3
+ export declare function up(db: Kysely<unknown>): Promise<void>;
4
+ export declare function down(db: Kysely<unknown>): Promise<void>;
@@ -0,0 +1,26 @@
1
+ import { sql } from "kysely";
2
+ export async function up(db) {
3
+ await sql`CREATE TABLE IF NOT EXISTS chat_history_revisions (
4
+ workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
5
+ thread_id TEXT NOT NULL, value INTEGER NOT NULL DEFAULT 0,
6
+ PRIMARY KEY (workspace_id, thread_id))`.execute(db);
7
+ await sql`CREATE INDEX IF NOT EXISTS s_messages_history_order ON s_messages
8
+ (workspace_id, json_extract(data_json, '$.thread_id'), json_extract(data_json, '$.index'), json_extract(data_json, '$.order_key'), id)`.execute(db);
9
+ for (const table of ["s_threads", "s_messages"]) for (const event of ["INSERT", "UPDATE", "DELETE"]) {
10
+ const rowNames = event === "UPDATE" ? ["OLD", "NEW"] : [event === "DELETE" ? "OLD" : "NEW"];
11
+ const statements = rowNames.map((row) => {
12
+ const thread = table === "s_threads" ? `${row}.id` : `json_extract(${row}.data_json, '$.thread_id')`;
13
+ return `INSERT INTO chat_history_revisions (workspace_id, thread_id, value)
14
+ SELECT ${row}.workspace_id, ${thread}, 1 WHERE ${thread} IS NOT NULL
15
+ ON CONFLICT (workspace_id, thread_id) DO UPDATE SET value = chat_history_revisions.value + 1;`;
16
+ }).join("\n");
17
+ await sql.raw(`CREATE TRIGGER IF NOT EXISTS ${table}_history_${event.toLowerCase()} AFTER ${event} ON ${table} BEGIN ${statements} END`).execute(db);
18
+ }
19
+ }
20
+ export async function down(db) {
21
+ for (const table of ["s_threads", "s_messages"]) for (const event of ["insert", "update", "delete"]) {
22
+ await sql.raw(`DROP TRIGGER IF EXISTS ${table}_history_${event}`).execute(db);
23
+ }
24
+ await sql`DROP INDEX IF EXISTS s_messages_history_order`.execute(db);
25
+ await sql`DROP TABLE IF EXISTS chat_history_revisions`.execute(db);
26
+ }
@@ -0,0 +1,4 @@
1
+ import { type Kysely } from 'kysely';
2
+ /** Match the reader's legacy missing-order-key normalization exactly. */
3
+ export declare function up(db: Kysely<unknown>): Promise<void>;
4
+ export declare function down(db: Kysely<unknown>): Promise<void>;
@@ -0,0 +1,24 @@
1
+ import { sql } from "kysely";
2
+ import { up as installCanonicalHistory } from "./022_canonical_chat_reader.js";
3
+ export async function up(db) {
4
+ for (const table of ["s_threads", "s_messages"]) for (const event of ["insert", "update", "delete"]) {
5
+ await sql.raw(`DROP TRIGGER IF EXISTS ${table}_history_${event}`).execute(db);
6
+ }
7
+ await sql`CREATE TABLE chat_history_revisions_v2 (
8
+ workspace_id TEXT NOT NULL, thread_id TEXT NOT NULL, value INTEGER NOT NULL DEFAULT 0,
9
+ PRIMARY KEY (workspace_id, thread_id))`.execute(db);
10
+ await sql`INSERT INTO chat_history_revisions_v2 SELECT workspace_id, thread_id, value FROM chat_history_revisions`.execute(db);
11
+ await sql`DROP TABLE chat_history_revisions`.execute(db);
12
+ await sql`ALTER TABLE chat_history_revisions_v2 RENAME TO chat_history_revisions`.execute(db);
13
+ await installCanonicalHistory(db);
14
+ await sql`DROP INDEX IF EXISTS s_messages_history_order`.execute(db);
15
+ await sql`CREATE INDEX s_messages_history_order ON s_messages
16
+ (workspace_id, json_extract(data_json, '$.thread_id'), json_extract(data_json, '$.index'),
17
+ COALESCE(json_extract(data_json, '$.order_key'), ''), id)`.execute(db);
18
+ }
19
+ export async function down(db) {
20
+ await sql`DROP INDEX IF EXISTS s_messages_history_order`.execute(db);
21
+ await sql`CREATE INDEX s_messages_history_order ON s_messages
22
+ (workspace_id, json_extract(data_json, '$.thread_id'), json_extract(data_json, '$.index'),
23
+ json_extract(data_json, '$.order_key'), id)`.execute(db);
24
+ }
@@ -207,6 +207,7 @@ export interface BackgroundJobsTable {
207
207
  completed_at: number | null;
208
208
  error: string | null;
209
209
  tool_calls_json: string | null;
210
+ usage_json: string | null;
210
211
  workflow_state_json: string | null;
211
212
  execution_json: string | null;
212
213
  idempotency_key: string | null;
@@ -9,20 +9,26 @@ import type { H3Event } from 'h3';
9
9
  import type { CanonicalStorageQueryRequest, CanonicalStorageQueryResponse, SyncGatewayAdapter, SyncMaintenanceState, UploadIntentConsumptionRequest, UploadIntentReservationRequest } from '~~/server/sync/gateway/types';
10
10
  import type { AdmitChatGenerationResult, CanonicalGenerationSnapshot, CanonicalHistoryActor, ChatGenerationAdmissionEnvelope, FinalizeChatGenerationResult } from '~~/shared/chat/background-history';
11
11
  import type { PullRequest, PullResponse, PushBatch, PushResult, SnapshotRequest, SnapshotResponse } from '~~/shared/sync/types';
12
+ import { type CanonicalChatQuery, type CanonicalChatReadResult } from '~~/shared/chat/history-reader';
12
13
  export declare class SqliteSyncGatewayAdapter implements SyncGatewayAdapter {
13
14
  id: string;
14
15
  get capabilities(): {
15
16
  readonly snapshotBootstrap: "snapshot-v1";
16
17
  readonly historyRetention: "snapshot-v1";
17
18
  readonly backgroundGenerationHistory?: undefined;
19
+ readonly canonicalChatHistory?: undefined;
20
+ readonly projectOwnership?: undefined;
18
21
  readonly workspaceItems?: undefined;
19
22
  } | {
20
23
  readonly snapshotBootstrap: "snapshot-v1";
21
24
  readonly historyRetention: "snapshot-v1";
22
25
  readonly backgroundGenerationHistory: "v1";
26
+ readonly canonicalChatHistory: "v1";
27
+ readonly projectOwnership: "v1";
23
28
  readonly workspaceItems: "v1";
24
29
  };
25
30
  private get db();
31
+ readChatHistory(actor: CanonicalHistoryActor, query: CanonicalChatQuery, signal?: AbortSignal): Promise<CanonicalChatReadResult>;
26
32
  private assertCanonicalActor;
27
33
  admitChatGeneration(actor: CanonicalHistoryActor, input: ChatGenerationAdmissionEnvelope): Promise<AdmitChatGenerationResult>;
28
34
  finalizeChatGeneration(actor: CanonicalHistoryActor, input: {
@@ -20,6 +20,8 @@ import { SYNCED_TABLE_MAP, ALLOWED_SYNC_TABLES } from "../db/schema.js";
20
20
  import { emitWebhookSystemHook } from "~~/server/utils/webhooks/runtime";
21
21
  import { incomingRevisionWins } from "~~/shared/sync/revision";
22
22
  import { sanitizePayloadForSync } from "~~/shared/sync/sanitize";
23
+ import { readRequestUsage } from "~~/shared/chat/compaction";
24
+ import { validateCanonicalChatQuery, encodeCanonicalChatSeek, parseCanonicalChatSeek } from "~~/shared/chat/history-reader";
23
25
  import { computePullRetention } from "./history-gc-policy.js";
24
26
  const DEFAULT_PULL_LIMIT = 100;
25
27
  const MAX_PULL_LIMIT = 1e3;
@@ -430,12 +432,78 @@ export class SqliteSyncGatewayAdapter {
430
432
  snapshotBootstrap: "snapshot-v1",
431
433
  historyRetention: "snapshot-v1",
432
434
  backgroundGenerationHistory: "v1",
435
+ canonicalChatHistory: "v1",
436
+ projectOwnership: "v1",
433
437
  workspaceItems: "v1"
434
438
  };
435
439
  }
436
440
  get db() {
437
441
  return getSqliteDb();
438
442
  }
443
+ async readChatHistory(actor, query, signal) {
444
+ validateCanonicalChatQuery(query);
445
+ signal?.throwIfAborted();
446
+ if (isD1Driver()) return { status: "scope_incomplete" };
447
+ const raw = getRawDb();
448
+ const result = raw.transaction(() => {
449
+ const membership = raw.prepare(`SELECT m.role FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id
450
+ WHERE m.workspace_id = ? AND m.user_id = ? AND COALESCE(w.deleted, 0) = 0`).get(actor.workspaceId, actor.userId);
451
+ if (!membership || !["owner", "editor", "viewer"].includes(membership.role)) throw new Error("Forbidden canonical history actor");
452
+ const counter = raw.prepare("SELECT value FROM server_version_counter WHERE workspace_id = ?").get(actor.workspaceId);
453
+ const revision = String(counter?.value ?? 0);
454
+ const parse = (row) => {
455
+ const payload = JSON.parse(row.data_json);
456
+ if (payload.id !== row.id) throw new Error("Canonical history identity mismatch");
457
+ return { ...payload, id: row.id, clock: row.clock, deleted: row.deleted === 1 };
458
+ };
459
+ if (query.kind === "thread") {
460
+ const threadRevision = raw.prepare("SELECT value FROM chat_history_revisions WHERE workspace_id = ? AND thread_id = ?").get(actor.workspaceId, query.thread_id);
461
+ const row = raw.prepare("SELECT id, data_json, clock, deleted FROM s_threads WHERE workspace_id = ? AND id = ?").get(actor.workspaceId, query.thread_id);
462
+ const thread = row ? parse(row) : void 0;
463
+ if (!thread || thread.project_id) return {
464
+ status: "ok",
465
+ project_ownership: "resolved",
466
+ revision: String(threadRevision?.value ?? 0),
467
+ thread
468
+ };
469
+ const projects = raw.prepare("SELECT id, data_json FROM s_projects WHERE workspace_id = ? AND COALESCE(deleted, 0) = 0").all(actor.workspaceId);
470
+ const owners = projects.filter((project) => {
471
+ const payload = JSON.parse(project.data_json);
472
+ const entries = typeof payload.data === "string" ? JSON.parse(payload.data) : payload.data ?? [];
473
+ if (!Array.isArray(entries)) throw new Error("Canonical project membership is unresolved.");
474
+ return entries.some((entry) => typeof entry === "string" ? entry === query.thread_id : entry && typeof entry === "object" && !Array.isArray(entry) && (entry.kind === void 0 || entry.kind === "chat") && entry.id === query.thread_id);
475
+ });
476
+ return {
477
+ status: "ok",
478
+ project_ownership: owners.length > 1 ? "conflict" : "resolved",
479
+ revision: String(threadRevision?.value ?? 0),
480
+ thread: { ...thread, project_id: owners[0]?.id ?? null }
481
+ };
482
+ }
483
+ if (query.kind === "messages") {
484
+ if (!query.message_ids.length) return { status: "ok", revision, messages: [] };
485
+ const rows2 = raw.prepare(`SELECT id, data_json, clock, deleted FROM s_messages WHERE workspace_id = ? AND id IN (${query.message_ids.map(() => "?").join(",")})`).all(actor.workspaceId, ...query.message_ids);
486
+ return { status: "ok", revision, messages: rows2.map(parse) };
487
+ }
488
+ let after;
489
+ const seek = parseCanonicalChatSeek(query.cursor, query.thread_id);
490
+ if (seek) after = seek.key;
491
+ else if (query.cursor) {
492
+ const value = JSON.parse(query.cursor);
493
+ if (!Array.isArray(value) || value.length !== 4 || value[0] !== query.thread_id || !Number.isSafeInteger(value[1]) || typeof value[2] !== "string" || typeof value[3] !== "string") throw new Error("Invalid canonical history cursor");
494
+ after = [value[1], value[2], value[3]];
495
+ }
496
+ const rows = raw.prepare(`SELECT id, data_json, clock, deleted FROM s_messages
497
+ WHERE workspace_id = ? AND json_extract(data_json, '$.thread_id') = ?
498
+ ${after ? `AND (json_extract(data_json, '$.index'), COALESCE(json_extract(data_json, '$.order_key'), ''), id) ${seek?.backward ? "<" : ">"} (?, ?, ?)` : ""}
499
+ ORDER BY json_extract(data_json, '$.index') ${seek?.backward ? "DESC" : "ASC"}, COALESCE(json_extract(data_json, '$.order_key'), '') ${seek?.backward ? "DESC" : "ASC"}, id ${seek?.backward ? "DESC" : "ASC"} LIMIT ?`).all(actor.workspaceId, query.thread_id, ...after ?? [], query.limit);
500
+ const messages = rows.map(parse);
501
+ const last = messages.at(-1);
502
+ return { status: "ok", revision, messages, next_cursor: messages.length === query.limit && last ? seek ? encodeCanonicalChatSeek({ ...seek, key: [Number(last.index), String(last.order_key ?? ""), last.id] }) : JSON.stringify([query.thread_id, last.index, last.order_key ?? "", last.id]) : void 0 };
503
+ })();
504
+ signal?.throwIfAborted();
505
+ return result;
506
+ }
439
507
  assertCanonicalActor(actor) {
440
508
  if (isD1Driver()) {
441
509
  throw new Error("SQLite D1 does not support background generation history v1");
@@ -641,6 +709,7 @@ export class SqliteSyncGatewayAdapter {
641
709
  const hlc = `${Date.now().toString(36).padStart(9, "0")}:000:background`;
642
710
  const opId = backgroundHistoryOpId(admission, "finalize");
643
711
  const terminalState = input.snapshot.status === "complete" ? "complete" : input.snapshot.status === "aborted" ? "aborted" : "failed";
712
+ const usage = readRequestUsage(input.snapshot.usage);
644
713
  const payload = {
645
714
  ...current,
646
715
  pending: false,
@@ -651,6 +720,7 @@ export class SqliteSyncGatewayAdapter {
651
720
  op_id: opId,
652
721
  data: {
653
722
  ...data,
723
+ ...usage ? { usage } : {},
654
724
  content: input.snapshot.content,
655
725
  reasoning_text: input.snapshot.reasoning || null,
656
726
  tool_calls: input.snapshot.toolCalls ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "or3-provider-sqlite",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/Saluana/or3-provider-sqlite.git"
@@ -38,7 +38,8 @@
38
38
  "lint": "eslint src --max-warnings 0",
39
39
  "type-check": "tsc --noEmit",
40
40
  "type-check:standalone": "tsc -p tsconfig.release.json --noEmit",
41
- "test": "vitest run"
41
+ "test": "vitest run",
42
+ "test:host-integration": "vitest run --config vitest.host-integration.config.ts"
42
43
  },
43
44
  "publishConfig": {
44
45
  "access": "public"
@@ -65,6 +66,7 @@
65
66
  "h3": "^1.15.4",
66
67
  "pathe": "^2.0.3",
67
68
  "typescript": "^5.9.3",
68
- "vitest": "^4.0.18"
69
+ "vitest": "^4.0.18",
70
+ "zod": "4.6.5"
69
71
  }
70
72
  }