Rindle

API index and search · Build metadata

Source snapshot

packages/daemon-client/src/index.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
1export type WireValue = number | string | boolean | null;23export type StreamMode = "flat" | "normalized";45export type MaterializationPolicy =6  | { kind: "pinned"; name?: string }7  | { kind: "whileSubscribed"; idleTtlMs?: number };89export interface MaterializeInput {10  ast: unknown;11  mode?: StreamMode;12  policy?: MaterializationPolicy;13  subject?: string;14  leaseTtlMs?: number;15  maxSubscribers?: number;16  metadata?: Record<string, unknown>;17  /** The browser's opaque follower-affinity ticket (FOLLOWER-AFFINITY-DESIGN.md §3), forwarded by18   *  the api-server. {@link HttpRindleDaemonClient} lifts it into the `Rindle-Affinity` request19   *  HEADER (never the body) so the fleet edge routes this `/materialize` to the follower the20   *  browser's ws is pinned to (§4) — both legs co-locate on one follower. Placement, not21   *  authorization: the follower verifies it, the api-server forwards it opaquely. Absent ⇒ no22   *  affinity (single daemon / affinity off), byte-identical to today. */23  affinity?: string;24}2526export interface MaterializeOutput {27  materializationId: string;28  queryKey?: string;29  leaseToken: string;30  reused?: boolean;31  /** Fresh follower-placement ticket returned by an affinity-enabled daemon. A machine client32   *  opening a separate ws for this follower-local lease (for example a room upstream) offers it33   *  as a subprotocol so the fleet edge routes that socket back to the minting follower. */34  affinity?: string;35}3637export interface DematerializeInput {38  materializationId?: string;39  queryKey?: string;40  name?: string;41}4243export interface DematerializeOutput {44  removed: boolean;45}4647export interface SqlStatement {48  sql: string;49  params?: WireValue[];50}5152/** A foreign (non-client) writer's durable identity: a stable, low-cardinality `id` plus that53 *  producer's own gapless sequence — the same `(producer, sequence)` model Kafka uses.54 *55 *  The daemon stores ONE row per producer and overwrites it in place, so the dedup state is56 *  bounded by how many producers you have, not by how much you write. The contract that buys:57 *  number a producer's writes `1, 2, 3, …` and send them **in order**. `seq <= last` is absorbed58 *  (`applied: false`, nothing runs), `seq == last + 1` applies, and anything beyond is a `409`59 *  gap. Express concurrency with several producer ids, not by fanning out under one — and keep60 *  ids stable and few, because a fresh id per process re-introduces unbounded growth. */61export interface WriteProducer {62  id: string;63  seq: number;64}6566export interface SqlTxn {67  /** A foreign write's dedup watermark. Mutually exclusive with `clientID`/`mid` — supplying68   *  both is a `400`, because a silent precedence rule is what let the two write authorities69   *  disagree about which receipt they stored. */70  producer?: WriteProducer;71  clientID?: string;72  mid?: number;73  statements: SqlStatement[];74}7576export interface SqlTxnOutput {77  applied?: boolean;78  cv?: number;79  txId?: number | string;80  /** Replicator-master commit identity (the public mutation SQL facade calls this `cursor`). */81  cursor?: string;82  /** Flat convenience mirror of this client's entry in `lmidAdvances`. */83  lmid?: number;84  lmidAdvances?: Array<{ clientID: string; lmid: number }>;85}8687/** A raw read: one `SELECT` and its positional parameters. The read counterpart to {@link SqlTxn}88 *  — no idempotency/mutation fields (reads are idempotent).89 *90 *  `consistency` / `routingKey` are **client-tier routing directives**: `consistency` is consumed91 *  by {@link SplitDaemonClient}, `routingKey` by the read router. They ride along the wire (the92 *  router is reached through an `HttpRindleDaemonClient`, so `routingKey` must) but the daemon93 *  itself ignores them and reads only `sql` + `params`. `consistency` defaults to `"eventual"`94 *  (route to a replica, scaling reads off the write-master); set `"strong"` to route to the master95 *  for read-your-writes after a write to the same data. `routingKey` co-locates a replica read on a96 *  chosen follower (e.g. by tenant); absent, the router places it by the SQL text. */97export interface SqlRead {98  sql: string;99  params?: WireValue[];100  consistency?: "eventual" | "strong";101  routingKey?: string;102}103104/** The `/execute-sql-read` response: result columns in order and each row as a bare cell array105 *  (NOT keyed objects — the daemon keeps per-cell object construction off the write-master; zip106 *  `cols` with each row client-side if you want objects). Lossless for duplicate/aliased columns. */107export interface SqlReadOutput {108  cols: string[];109  rows: WireValue[][];110}111112export type RowChangeTxn = {113  source: string;114  offset: string;115  changes: Array<116    | { table: string; op: "add"; row: WireValue[] }117    | { table: string; op: "remove"; old: WireValue[] }118    | { table: string; op: "edit"; old: WireValue[]; row: WireValue[] }119  >;120  /** The room write-behind extensions (RINDLE-REALTIME §5.3, each opt-in by121   *  presence): the placement fence (`doc` + `epoch` — a stale epoch gets a122   *  `409 {error:"fenced"}`), the §8.3 batch identity (`batchHash` — same id +123   *  different body is a loud 500), and the CAS preconditions (`cas: true` — a miss124   *  gets a `409 {error:"conflict", conflicts}` with authoritative images, nothing125   *  applied). Plain change sources send none and are byte-compatible unchanged. */126  doc?: string;127  epoch?: number;128  batchHash?: string;129  cas?: boolean;130};131132export interface RowChangeTxnOutput {133  /** `false` = the `(source, offset)` keyset absorbed a replay. */134  applied?: boolean;135  cv?: number;136}137138/** `/claim-room-epoch` (RINDLE-REALTIME §2.5): bump + return the placement epoch. */139export interface ClaimRoomEpochInput {140  doc: string;141}142export interface ClaimRoomEpochOutput {143  epoch: number;144}145146/** `/room-lmids` (§3.3): the domain-scoped ledger's lmid per client under `doc` — the147 *  room's boot probe. `doc` scopes the read to `_rindle_room_client_mutations` (§7.1), so a148 *  client's room stream never aliases its slow-path daemon stream. */149export interface RoomLmidsInput {150  doc: string;151  clients: string[];152}153export interface RoomLmidsOutput {154  lmids: Record<string, number>;155}156157/** `/mutate-session/begin` (DAEMON-INTERACTIVE-TXN-DESIGN.md §4.1): open an interactive write158 *  transaction on the master, held open across round trips under the daemon-owned deadline.159 *  Client-attributed sessions (`clientID` + `mid`) get mid dedup/gap resolution UP FRONT — an160 *  absorbed replay opens no session and the caller must skip the mutator. `statements` is the161 *  accumulated write prefix to replay into the fresh transaction (sound: nothing before the162 *  first read observed DB state); `query` rides the begin so a one-read mutator pays exactly163 *  one extra round trip over the batch path. */164export interface MutationSessionBegin {165  clientID?: string;166  mid?: number;167  /** A foreign session's dedup watermark; see {@link WriteProducer}. Mutually exclusive with168   *  `clientID`/`mid`. */169  producer?: WriteProducer;170  statements?: SqlStatement[];171  query?: SqlStatement;172}173174export interface MutationSessionBeginOutput {175  /** Present iff a session opened. */176  sessionId?: string;177  /** The `query` read's result, when one rode the begin (`{cols, rows}`, bare cell arrays). */178  read?: SqlReadOutput;179  /** True when mid/producer-watermark dedup absorbed the envelope at begin: NO session opened, the180   *  mutator must not run — the remaining fields are the authoritative replay output, the same181   *  shape {@link RindleDaemonClient.executeSqlTxn} answers for a deduped mid. */182  absorbed?: boolean;183  applied?: boolean;184  lmid?: number;185  lmidAdvances?: Array<{ clientID: string; lmid: number }>;186}187188export interface MutationSessionExec {189  sessionId: string;190  statements: SqlStatement[];191}192193/** A read through the open transaction (read-your-writes) — flat `{sessionId, sql, params}`,194 *  answered in the `/execute-sql-read` shape. */195export interface MutationSessionQuery {196  sessionId: string;197  sql: string;198  params?: WireValue[];199}200201export interface MutationSessionRef {202  sessionId: string;203}204205export interface MutationRejection {206  clientID: string;207  mid: number;208  reason?: string;209}210211export interface MutationRejectionOutput {212  cv?: number;213  lmid?: number;214}215216/** One pure migration file: either ordered DDL statements or ordered DML statements. The opaque217 *  `id` is the journaled idempotency key. `checksum` is the canonical SHA-256 content identity218 *  sent by current deploy tooling; it is required for data migrations and optional only for219 *  compatibility with legacy private DDL callers. `overrideHash` is an operator-reviewed checksum220 *  for the content this migration originally applied with. DDL may be additive or destructive,221 *  while `RENAME` and raw `blob` remain unsupported. */222export interface MigrateInput {223  id: string;224  checksum?: string;225  overrideHash?: string;226  statements: string[];227}228229/** The `/migrate` response: `applied` is false when `id` was already journaled (an idempotent230 *  replay ran nothing); `hashOverridden` reports that the supplied override matched the journaled231 *  content identity; `schemaVersion` is the version the daemon will report AFTER it serves the new232 *  shape (the latest applied migration id, `DRIZZLE-MIGRATIONS-DESIGN.md` §5.3). `restarting` is233 *  true when the daemon is self-restarting to re-introspect (a supervised daemon with234 *  `RINDLE_RESTART_ON_MIGRATE`) — the caller should wait for it to come back before its next call. */235export interface MigrateOutput {236  applied: boolean;237  hashOverridden?: boolean;238  schemaVersion?: string;239  restarting?: boolean;240}241242/** One entry in a {@link MigrateBatchOutput}: the migration `id`, whether this apply ran it243 *  (false = the `id` was already journaled, an idempotent no-op), and whether its override matched244 *  the journaled content identity. */245export interface MigrateResultItem {246  id: string;247  applied: boolean;248  hashOverridden?: boolean;249  schemaVersion?: string;250}251252/** The response to the ARRAY form of `/migrate` ({@link HttpRindleDaemonClient.migrateBatch}): a253 *  whole ordered set applied in one round-trip so a supervised daemon self-restarts ONCE for the254 *  set instead of once per migration. `results` are the migrations processed before any failure, in255 *  order; `applied` counts the newly-applied ones; `schemaVersion` is the latest applied id;256 *  `restarting` is true when the daemon is self-restarting to re-introspect (wait for it before the257 *  next call). On a failed migration `error`/`failedId` name it — the batch stops there, earlier258 *  migrations are committed (forward-only), and the daemon does NOT restart: fix forward and259 *  re-send. Unlike the single-migration form, this always arrives as HTTP 200; inspect `error`. */260export interface MigrateBatchOutput {261  results: MigrateResultItem[];262  applied: number;263  schemaVersion?: string;264  restarting?: boolean;265  error?: string;266  errorCode?: string;267  failedId?: string;268}269270/** The SSR one-shot read (`SSR-DESIGN.md` §3). Materialize-or-reuse the query, read its current271 *  view ONCE, return it assembled; registers no subscriber. `visibilityKey`/`ttlMs` are optional. */272export interface QueryOnceInput {273  ast: unknown;274  visibilityKey?: string;275  ttlMs?: number;276  /** The browser's opaque follower-affinity ticket — see {@link MaterializeInput.affinity}. Lifted277   *  into the `Rindle-Affinity` header so an SSR / one-shot read co-locates on the same pinned278   *  follower the browser's ws is (or will be) on. */279  affinity?: string;280}281282/** The `POST /query` response: assembled `rows` (nested by name, ready to hydrate without an283 *  engine), the `cvMin` baseline they reflect, the per-query `schema` hello, and `queryKey`. */284export interface QueryOnceOutput {285  queryKey?: string;286  cvMin?: number;287  bootId?: string;288  schema?: unknown;289  rows: Array<{ cols: Record<string, WireValue>; [rel: string]: unknown }>;290}291292/** One column in a {@link SchemaTable}: its name and the daemon's `ColType` wire name. */293export interface SchemaColumn {294  name: string;295  /** The daemon's `ColType` wire name. Because the daemon only ever *introspects* the SQLite file296   *  and affinity is lossy, this is `"string"` or `"number"` in practice — a column's app-level297   *  `"boolean"`/`"json"` intent is not recoverable from the file (`DRIZZLE-MIGRATIONS-DESIGN.md`298   *  §6.2). The full vocabulary is `"string" | "number" | "boolean" | "json"`. */299  type: string;300  /** `true` when the column is nullable (introspected `pragma_table_info.notnull == 0`); the301   *  generated column becomes `.nullable()`, typing it `T | null`. PK columns are always `false`302   *  (row identity). Absent on older daemons ⇒ treat as non-nullable. See design 206. */303  nullable?: boolean;304}305306/** One base table in the daemon's introspected schema: its name, ordered columns, and PK columns. */307export interface SchemaTable {308  name: string;309  columns: SchemaColumn[];310  primaryKey: string[];311}312313/** The daemon's introspected base-table schema (`GET /schema`) — the input to client-schema314 *  codegen (`DRIZZLE-MIGRATIONS-DESIGN.md` §6.2). Tables are sorted by name and the daemon's own315 *  bookkeeping tables (`_rindle_*`) are excluded. */316export interface SchemaOutput {317  tables: SchemaTable[];318}319320export interface RindleDaemonClient {321  materialize(input: MaterializeInput): Promise<MaterializeOutput>;322  dematerialize(input: DematerializeInput): Promise<DematerializeOutput>;323  executeSqlTxn(input: SqlTxn): Promise<SqlTxnOutput>;324  /** Raw SQL read (a single `SELECT` + params) against the latest committed snapshot, returning325   *  `{ cols, rows }` (bare row arrays). The read counterpart to {@link executeSqlTxn}. Under326   *  {@link SplitDaemonClient} this defaults to a replica (`consistency:"eventual"`); pass327   *  `consistency:"strong"` for read-your-writes on the master. */328  executeSqlRead(input: SqlRead): Promise<SqlReadOutput>;329  applyRowChangeTxn(input: RowChangeTxn): Promise<RowChangeTxnOutput>;330  rejectMutation(input: MutationRejection): Promise<MutationRejectionOutput>;331  /** SSR one-shot read (`SSR-DESIGN.md` §3): the current assembled view, no subscription. */332  query(input: QueryOnceInput): Promise<QueryOnceOutput>;333  /** Apply one schema migration through the daemon's controlled DDL channel + journal it334   *  (`DRIZZLE-MIGRATIONS-DESIGN.md` §5.1). Idempotent by `id`. The new shape is served only after335   *  the daemon is bounced (migrate-at-bounce, §5.2) — this applies DDL to the file but does not336   *  re-introspect the running engine. Drive it from a migration runner, not the request path. */337  migrate(input: MigrateInput): Promise<MigrateOutput>;338  /** Open an interactive mutation session (DAEMON-INTERACTIVE-TXN-DESIGN.md §4) — the write339   *  transaction a read-bearing mutator lazily upgrades onto. Optional as a group with the four340   *  ops below: only the daemon's control plane serves sessions (master-only — a follower's341   *  write-fence rejects begin); a client that lacks them forces the legacy committed-state342   *  read path. Session ops against a finished/expired session reject with a343   *  {@link DaemonHttpError} whose `status` is 410 — treat it as infra (retry the envelope;344   *  begin-time dedup absorbs a committed outcome). */345  beginMutationSession?(input: MutationSessionBegin): Promise<MutationSessionBeginOutput>;346  execInMutationSession?(input: MutationSessionExec): Promise<unknown>;347  queryInMutationSession?(input: MutationSessionQuery): Promise<SqlReadOutput>;348  commitMutationSession?(input: MutationSessionRef): Promise<SqlTxnOutput>;349  rollbackMutationSession?(input: MutationSessionRef): Promise<unknown>;350  /** Claim the next placement epoch for a room's doc (RINDLE-REALTIME §2.5). Optional:351   *  only daemons serving as a room write authority implement it; the API server's352   *  room host refuses loudly when its configured client lacks it. */353  claimRoomEpoch?(input: ClaimRoomEpochInput): Promise<ClaimRoomEpochOutput>;354  /** The room's boot probe (§3.3). Optional, same contract as {@link claimRoomEpoch}. */355  roomLmids?(input: RoomLmidsInput): Promise<RoomLmidsOutput>;356}357358export interface HttpRindleDaemonClientPaths {359  materialize: string;360  dematerialize: string;361  executeSqlTxn: string;362  executeSqlRead: string;363  applyRowChangeTxn: string;364  rejectMutation: string;365  query: string;366  migrate: string;367  schema: string;368  claimRoomEpoch: string;369  roomLmids: string;370  mutateSessionBegin: string;371  mutateSessionExec: string;372  mutateSessionQuery: string;373  mutateSessionCommit: string;374  mutateSessionRollback: string;375}376377export type HeaderValue = string | undefined;378export type HeadersInput = Record<string, HeaderValue>;379export type HeadersFactory = () => HeadersInput | PromiseLike<HeadersInput>;380381export interface FetchResponseLike {382  ok: boolean;383  status: number;384  statusText: string;385  text(): Promise<string>;386  /** Optional response headers — the real `fetch` Response exposes these. Used to read the387   *  daemon's `Rindle-Boot-Id` (see {@link HttpRindleDaemonClientOptions.onBootId}). */388  headers?: { get(name: string): string | null };389}390391export type FetchLike = (392  input: string,393  init: { method: string; headers: Record<string, string>; body: string },394) => Promise<FetchResponseLike>;395396export interface HttpRindleDaemonClientOptions {397  baseUrl: string;398  fetch?: FetchLike;399  headers?: HeadersInput | HeadersFactory;400  paths?: Partial<HttpRindleDaemonClientPaths>;401  /** Fired with the daemon's boot id on the first control-plane response and again whenever it402   *  CHANGES — i.e. the daemon restarted (it keeps no durable lease/materialization state). Use403   *  it to re-assert pins / re-materialize. Treat every call as "(re)assert now"; it rides404   *  responses you already make (e.g. an ingester's writes), so no polling is needed. Must not405   *  throw or reject — handle your own errors (e.g. `onBootId: () => server.assertPins().catch(log)`). */406  onBootId?: (bootId: string) => void;407}408409/** The api-server → fleet control-plane header carrying the browser's opaque affinity ticket on410 *  `/materialize` and `/query` (FOLLOWER-AFFINITY-DESIGN.md §5). Inlined (not imported from411 *  `@rindle/affinity`'s `HEADER_NAME`) to keep this client free of that crate's `node:crypto`412 *  dependency — an api-server may run on a Worker. Keep the two spellings in lock-step. */413const AFFINITY_HEADER = "Rindle-Affinity";414415const defaultPaths: HttpRindleDaemonClientPaths = {416  materialize: "/materialize",417  dematerialize: "/dematerialize",418  executeSqlTxn: "/execute-sql-txn",419  executeSqlRead: "/execute-sql-read",420  applyRowChangeTxn: "/apply-row-change-txn",421  rejectMutation: "/reject-mutation",422  query: "/query",423  migrate: "/migrate",424  schema: "/schema",425  claimRoomEpoch: "/claim-room-epoch",426  roomLmids: "/room-lmids",427  mutateSessionBegin: "/mutate-session/begin",428  mutateSessionExec: "/mutate-session/exec",429  mutateSessionQuery: "/mutate-session/query",430  mutateSessionCommit: "/mutate-session/commit",431  mutateSessionRollback: "/mutate-session/rollback",432};433434export class DaemonHttpError extends Error {435  readonly status: number;436  readonly statusText: string;437  readonly body: string;438439  constructor(status: number, statusText: string, body: string) {440    super(`rindle daemon request failed: ${status} ${statusText}${body ? `: ${body}` : ""}`);441    this.name = "DaemonHttpError";442    this.status = status;443    this.statusText = statusText;444    this.body = body;445  }446}447448export class HttpRindleDaemonClient implements RindleDaemonClient {449  private readonly baseUrl: string;450  private readonly fetchImpl: FetchLike;451  private readonly headers?: HeadersInput | HeadersFactory;452  private readonly paths: HttpRindleDaemonClientPaths;453  private readonly onBootId?: (bootId: string) => void;454  private lastBootId?: string;455456  constructor(opts: HttpRindleDaemonClientOptions) {457    this.baseUrl = opts.baseUrl;458    this.fetchImpl = opts.fetch ?? defaultFetch;459    this.headers = opts.headers;460    this.paths = { ...defaultPaths, ...opts.paths };461    this.onBootId = opts.onBootId;462  }463464  materialize(input: MaterializeInput): Promise<MaterializeOutput> {465    // The affinity ticket rides the `Rindle-Affinity` HEADER, not the body (the fleet edge reads it466    // pre-dispatch this POST to the pinned follower). Strip it from the body so the467    // daemon's `MaterializeInput` deserialization never sees an unknown field.468    const { affinity, ...body } = input;469    return this.post(this.paths.materialize, body, affinity);470  }471472  dematerialize(input: DematerializeInput): Promise<DematerializeOutput> {473    return this.post(this.paths.dematerialize, input);474  }475476  executeSqlTxn(input: SqlTxn): Promise<SqlTxnOutput> {477    return this.post(this.paths.executeSqlTxn, input);478  }479480  executeSqlRead(input: SqlRead): Promise<SqlReadOutput> {481    // Sends the full input (like `query` carries `visibilityKey`): `routingKey` must survive this482    // hop so the read router — reached THROUGH this client — can place by it. The daemon itself483    // reads only `sql` + `params` and ignores the routing directives.484    return this.post(this.paths.executeSqlRead, input);485  }486487  applyRowChangeTxn(input: RowChangeTxn): Promise<RowChangeTxnOutput> {488    return this.post(this.paths.applyRowChangeTxn, input);489  }490491  claimRoomEpoch(input: ClaimRoomEpochInput): Promise<ClaimRoomEpochOutput> {492    return this.post(this.paths.claimRoomEpoch, input);493  }494495  roomLmids(input: RoomLmidsInput): Promise<RoomLmidsOutput> {496    return this.post(this.paths.roomLmids, input);497  }498499  rejectMutation(input: MutationRejection): Promise<MutationRejectionOutput> {500    return this.post(this.paths.rejectMutation, input);501  }502503  beginMutationSession(input: MutationSessionBegin): Promise<MutationSessionBeginOutput> {504    return this.post(this.paths.mutateSessionBegin, input);505  }506507  execInMutationSession(input: MutationSessionExec): Promise<unknown> {508    return this.post(this.paths.mutateSessionExec, input);509  }510511  queryInMutationSession(input: MutationSessionQuery): Promise<SqlReadOutput> {512    return this.post(this.paths.mutateSessionQuery, input);513  }514515  commitMutationSession(input: MutationSessionRef): Promise<SqlTxnOutput> {516    return this.post(this.paths.mutateSessionCommit, input);517  }518519  rollbackMutationSession(input: MutationSessionRef): Promise<unknown> {520    return this.post(this.paths.mutateSessionRollback, input);521  }522523  query(input: QueryOnceInput): Promise<QueryOnceOutput> {524    const { affinity, ...body } = input;525    return this.post(this.paths.query, body, affinity);526  }527528  migrate(input: MigrateInput): Promise<MigrateOutput> {529    return this.post(this.paths.migrate, input);530  }531532  /** Apply an ordered SET of migrations in one round-trip — the array form of {@link migrate}. The533   *  daemon applies them in order (stopping at the first failure) and, when supervised, self-restarts534   *  ONCE for the whole set. Drive it from a migration runner (`rindle migrate apply` does). See535   *  {@link MigrateBatchOutput} — inspect its `error` field rather than relying on the HTTP status. */536  migrateBatch(inputs: MigrateInput[]): Promise<MigrateBatchOutput> {537    return this.post(this.paths.migrate, inputs);538  }539540  /** The daemon's introspected base-table schema for client-schema codegen541   *  (`DRIZZLE-MIGRATIONS-DESIGN.md` §6.2). Read-only `GET /schema`, bearer-auth'd when the daemon542   *  has a token configured. Deliberately NOT on {@link RindleDaemonClient}: schema codegen is a543   *  dev/CI concern, not part of the runtime control-plane contract a router/splitter satisfies. */544  schema(): Promise<SchemaOutput> {545    return this.get(this.paths.schema);546  }547548  private async post<T>(path: string, input: unknown, affinity?: string): Promise<T> {549    const headers = await this.resolveHeaders();550    // The api-server → fleet control-plane affinity ticket rides as a header on top of the usual551    // bearer/content headers (FOLLOWER-AFFINITY-DESIGN.md §5); the fleet edge reads it to route552    // to the pinned follower. Only `/materialize` + `/query` carry one.553    if (affinity !== undefined) headers[AFFINITY_HEADER] = affinity;554    const res = await this.fetchImpl(urlJoin(this.baseUrl, path), {555      method: "POST",556      headers: { "content-type": "application/json", ...headers },557      body: JSON.stringify(input),558    });559    this.observeBootId(res);560    const body = await res.text();561    if (!res.ok) throw new DaemonHttpError(res.status, res.statusText, body);562    return (body ? JSON.parse(body) : undefined) as T;563  }564565  /** A bearer-auth'd GET (the read-only `/schema`, `/version`-style routes). Carries no request566   *  body — `defaultFetch` strips the placeholder before the real fetch (WHATWG fetch rejects a567   *  GET body). */568  private async get<T>(path: string): Promise<T> {569    const headers = await this.resolveHeaders();570    const res = await this.fetchImpl(urlJoin(this.baseUrl, path), {571      method: "GET",572      headers,573      body: "",574    });575    this.observeBootId(res);576    const body = await res.text();577    if (!res.ok) throw new DaemonHttpError(res.status, res.statusText, body);578    return (body ? JSON.parse(body) : undefined) as T;579  }580581  /** Notice the daemon's boot id on any response; fire `onBootId` on the first one and on every582   *  change (a restart). Updates `lastBootId` BEFORE the hook so a re-assert it triggers (which583   *  POSTs again, same boot id) does not re-fire — no recursion. */584  private observeBootId(res: FetchResponseLike): void {585    const bootId = res.headers?.get("rindle-boot-id") ?? undefined;586    if (!bootId || bootId === this.lastBootId) return;587    this.lastBootId = bootId;588    try {589      this.onBootId?.(bootId);590    } catch {591      // A misbehaving hook must never break the originating request.592    }593  }594595  private async resolveHeaders(): Promise<Record<string, string>> {596    const raw = typeof this.headers === "function" ? await this.headers() : (this.headers ?? {});597    const out: Record<string, string> = {};598    for (const [key, value] of Object.entries(raw)) {599      if (value !== undefined) out[key] = value;600    }601    return out;602  }603}604605function urlJoin(baseUrl: string, path: string): string {606  const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;607  const rel = path.startsWith("/") ? path.slice(1) : path;608  return new URL(rel, base).toString();609}610611type RealFetch = (612  input: string,613  init: { method: string; headers: Record<string, string>; body?: string },614) => Promise<FetchResponseLike>;615616const defaultFetch: FetchLike = async (input, init) => {617  const fetchImpl = (globalThis as { fetch?: RealFetch }).fetch;618  if (!fetchImpl) throw new Error("global fetch is unavailable; pass opts.fetch to HttpRindleDaemonClient");619  // A GET/HEAD must not carry a request body (WHATWG fetch throws); the client passes "" as a620  // placeholder for those, so drop it before reaching the real fetch.621  if (init.method === "GET" || init.method === "HEAD") {622    return fetchImpl(input, { method: init.method, headers: init.headers });623  }624  return fetchImpl(input, init);625};626