Skip to content

Reference source · 0.1.0-dev.11

queue.ts

src/queue.ts · Download original file

import { DatabaseSync } from 'node:sqlite';
import type { Client } from './sdk.ts';
import type { ResourceType } from './resource-profile.ts';
import type { Transport } from './sdk.ts';
import { DirectoryTrust } from './discovery.ts';
import { newId, id, requireThat, ProtocolError, fingerprint, jsonBytes } from './core.ts';

export type QueueState = 'pending' | 'submitted' | 'committed' | 'conflicted' | 'rejected';
export type WriteIntent = { operation: 'create_lab'; patient: string; resource: Record<string, any>; operation_id: string }
  | { operation: 'update_demographics'; patient: string; resource: { name: string }; base_version: number; operation_id: string }
  | { operation:'create_resource';patient:string;resource:Record<string,any>;operation_id:string }
  | { operation:'update_resource';patient:string;resource:Record<string,any>;base_version:number;operation_id:string }
  | { operation:'delete_resource';patient:string;resource_type:ResourceType;resource_id:string;base_version:number;operation_id:string };
export type QueueEntry = { sequence: number; operation_id: string; state: QueueState; intent: WriteIntent; result: Record<string, any> | null; error: string | null };

// One worker/one process per database in this reference adapter. Local cache encryption is operator-owned.
export class DurableQueue {
  db: DatabaseSync; client: Client; pumping = false;
  constructor(path: string, client: Client) {
    this.client = client; this.db = new DatabaseSync(path);
    this.db.exec(`PRAGMA journal_mode=WAL;
      CREATE TABLE IF NOT EXISTS owner (id INTEGER PRIMARY KEY CHECK(id=1), application TEXT NOT NULL);
      CREATE TABLE IF NOT EXISTS queue (sequence INTEGER PRIMARY KEY AUTOINCREMENT, operation_id TEXT UNIQUE NOT NULL, intent TEXT NOT NULL, digest TEXT NOT NULL, state TEXT NOT NULL, result TEXT, error TEXT);`);
    this.db.exec('CREATE TABLE IF NOT EXISTS queue_routes (patient TEXT PRIMARY KEY, host TEXT NOT NULL, generation INTEGER, address TEXT);');
    this.db.prepare('INSERT INTO owner VALUES (1, ?) ON CONFLICT(id) DO NOTHING').run(client.identity.public.subject);
    const owner = this.db.prepare('SELECT application FROM owner WHERE id=1').get() as any;
    if (owner.application !== client.identity.public.subject) { this.db.close(); throw new ProtocolError('wrong_queue_owner'); }
    // A process that stopped before saving a response cannot know whether the Host committed.
    // Retrying the unchanged operation with a new envelope resolves that uncertainty through its receipt.
    this.db.prepare('UPDATE queue SET state=? WHERE state=?').run('pending', 'submitted');
    for (const entry of this.entries()) this.pinRoute(entry.intent.patient);
  }
  pinRoute(patient: string): void {
    const location = this.client.location?.patient === patient ? this.client.location : undefined;
    this.db.prepare('INSERT INTO queue_routes VALUES (?, ?, ?, ?) ON CONFLICT(patient) DO NOTHING').run(patient, this.client.hostId, location?.generation ?? null, location?.address ?? null);
  }
  enqueue(intent: WriteIntent): string {
    id(intent.patient); id(intent.operation_id);
    this.pinRoute(intent.patient);
    const route = this.db.prepare('SELECT host FROM queue_routes WHERE patient=?').get(intent.patient) as any;
    requireThat(route.host === this.client.hostId, 'queue_route_update_required');
    const encoded = JSON.stringify(intent), digest = fingerprint(jsonBytes(intent));
    const prior = this.db.prepare('SELECT digest FROM queue WHERE operation_id=?').get(intent.operation_id) as any;
    if (prior) { requireThat(prior.digest === digest, 'queue_operation_id_conflict'); return intent.operation_id; }
    this.db.prepare('INSERT INTO queue(operation_id,intent,digest,state) VALUES (?, ?, ?, ?)').run(intent.operation_id, encoded, digest, 'pending');
    return intent.operation_id;
  }
  createLab(patient: string, resource: Record<string, any>, operationId = newId()): string {
    return this.enqueue({ operation: 'create_lab', patient, resource, operation_id: operationId });
  }
  updateDemographics(patient: string, name: string, baseVersion: number, operationId = newId()): string {
    return this.enqueue({ operation: 'update_demographics', patient, resource: { name }, base_version: baseVersion, operation_id: operationId });
  }
  createResource(patient: string, resource: Record<string, any>, operationId = newId()): string { return this.enqueue({operation:'create_resource',patient,resource,operation_id:operationId}); }
  updateResource(patient: string, resource: Record<string, any>, baseVersion: number, operationId = newId()): string { return this.enqueue({operation:'update_resource',patient,resource,base_version:baseVersion,operation_id:operationId}); }
  deleteResource(patient: string, type: ResourceType, resourceId: string, baseVersion: number, operationId = newId()): string { return this.enqueue({operation:'delete_resource',patient,resource_type:type,resource_id:resourceId,base_version:baseVersion,operation_id:operationId}); }
  entries(): QueueEntry[] {
    return (this.db.prepare('SELECT * FROM queue ORDER BY sequence').all() as any[]).map(row => ({ sequence: row.sequence, operation_id: row.operation_id, state: row.state, intent: JSON.parse(row.intent), result: row.result ? JSON.parse(row.result) : null, error: row.error }));
  }
  async followMigration(patient: string, document: Record<string, any>, address: string, directory: DirectoryTrust, transport: Transport): Promise<void> {
    requireThat(!this.pumping, 'queue_worker_busy'); id(patient);
    const route = this.db.prepare('SELECT * FROM queue_routes WHERE patient=?').get(patient) as any; requireThat(route, 'unknown_queue_route');
    const located = directory.validate(document, address, this.client.clock(), route.generation ?? 1);
    requireThat(located.record_id === patient && (!route.address || route.address === located.address), 'wrong_discovery_patient');
    requireThat(located.host === route.host || route.generation === null || located.generation > route.generation, 'stale_discovery_generation');
    // Save changed SDK state before changing the durable queue route; a failed save cannot send to a new Host.
    await this.client.followDiscovery(document, address, directory, transport, patient, route.generation ?? 1);
    this.db.prepare('UPDATE queue_routes SET host=?,generation=?,address=? WHERE patient=?').run(located.host, located.generation, located.address, patient);
  }
  async flush(): Promise<void> {
    requireThat(!this.pumping, 'queue_worker_busy'); this.pumping = true;
    try {
      for (;;) {
        const entry = this.entries().find(row => row.state !== 'committed');
        if (!entry || entry.state !== 'pending') return; // Never reorder later writes around a blocked dependency.
        this.db.prepare('UPDATE queue SET state=?, error=NULL WHERE operation_id=?').run('submitted', entry.operation_id);
        try {
          const intent = entry.intent;
          const route = this.db.prepare('SELECT host FROM queue_routes WHERE patient=?').get(intent.patient) as any;
          requireThat(route.host === this.client.hostId, 'queue_route_update_required');
          const result = intent.operation === 'create_resource' ? await this.client.createResource(intent.patient,intent.resource,intent.operation_id)
            : intent.operation === 'update_resource' ? await this.client.updateResource(intent.patient,intent.resource,intent.base_version,intent.operation_id)
            : intent.operation === 'delete_resource' ? await this.client.deleteResource(intent.patient,intent.resource_type,intent.resource_id,intent.base_version,intent.operation_id)
            : intent.operation === 'create_lab'
            ? await this.client.createLab(intent.patient, intent.resource, intent.operation_id)
            : await this.client.updateDemographics(intent.patient, intent.resource.name, intent.base_version, intent.operation_id);
          this.db.prepare('UPDATE queue SET state=?, result=?, error=NULL WHERE operation_id=?').run('committed', JSON.stringify(result), entry.operation_id);
        } catch (error) {
          const code = error instanceof ProtocolError ? error.code : 'transport_unavailable';
          const conflict = ['version_conflict', 'operation_id_conflict'].includes(code);
          const rejected = ['insufficient_scope', 'invalid_token', 'wrong_token_holder', 'authorization_expired_or_revoked', 'representative_not_authorized', 'authorization_expired', 'invalid_message', 'invalid_lab_category', 'not_found','resource_deleted','unsupported_resource_type','invalid_resource','invalid_resource_id','invalid_resource_code','invalid_resource_status','wrong_resource_patient','unsupported_resource_content','external_validation_rejected'].includes(code);
          this.db.prepare('UPDATE queue SET state=?, error=? WHERE operation_id=?').run(conflict ? 'conflicted' : rejected ? 'rejected' : 'pending', code, entry.operation_id);
          return; // Retry scheduling, backoff, and user resolution belong to the application.
        }
      }
    } finally { this.pumping = false; }
  }
  close(): void { requireThat(!this.pumping, 'queue_worker_busy'); this.db.close(); }
}