@agentic-kit/run-log-client 0.2.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.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The documents this lane needs, built from a module's names.
3
+ *
4
+ * Written as strings rather than generated: the run surface belongs to a
5
+ * tenant's schema, so there is no committed SDK target that carries it, and the
6
+ * fields queried here are exactly the columns the run generator emits.
7
+ *
8
+ * The shapes follow the conventions the generated targets in `sdk/` use against
9
+ * this platform's GraphQL: a collection takes `where: <Type>Filter` (not
10
+ * `filter`), there is no single-node query field — one row is a `first: 1`
11
+ * collection read — and an update takes `{ id, <singular>Patch }`.
12
+ */
13
+ export const RUN_FIELDS = `
14
+ id
15
+ threadId
16
+ actorId
17
+ entityId
18
+ status
19
+ placement
20
+ executionId
21
+ repoUrl
22
+ branch
23
+ baseCommit
24
+ headCommit
25
+ lastEventSeq
26
+ attempt
27
+ parentRunId
28
+ error
29
+ artifacts
30
+ deadlineAt
31
+ startedAt
32
+ finishedAt
33
+ createdAt`;
34
+ // The columns are named for what they describe rather than for the harness that
35
+ // writes them, which is the shape `@agentic-kit/run-log`'s record asserts.
36
+ export const EVENT_FIELDS = `
37
+ runId
38
+ seq
39
+ recordedAt
40
+ transcriptFormat
41
+ transcriptVersion
42
+ entry`;
43
+ export const runsDocument = (names) => `
44
+ query Runs($first: Int!, $where: ${names.runType}Filter) {
45
+ ${names.runs}(first: $first, where: $where, orderBy: [CREATED_AT_DESC]) {
46
+ nodes {${RUN_FIELDS}
47
+ }
48
+ }
49
+ }`;
50
+ export const runDocument = (names) => `
51
+ query Run($where: ${names.runType}Filter!) {
52
+ ${names.runs}(first: 1, where: $where) {
53
+ nodes {${RUN_FIELDS}
54
+ }
55
+ }
56
+ }`;
57
+ export const eventsDocument = (names) => `
58
+ query RunEvents($first: Int!, $where: ${names.eventType}Filter!) {
59
+ ${names.events}(first: $first, where: $where, orderBy: [SEQ_ASC]) {
60
+ nodes {${EVENT_FIELDS}
61
+ }
62
+ }
63
+ }`;
64
+ export const appendDocument = (names) => `
65
+ mutation AppendRunEvent($input: Create${names.eventType}Input!) {
66
+ ${names.createEvent}(input: $input) {
67
+ ${names.event} {${EVENT_FIELDS}
68
+ }
69
+ }
70
+ }`;
71
+ /**
72
+ * Open a run.
73
+ *
74
+ * A cloud run is opened by the worker on the platform's side of the callback
75
+ * lane; a local run has no worker, so the host that executes it opens its own
76
+ * row here — as the user, over the tenant API, which is what makes `actor_id`,
77
+ * `principal_id` and `entity_id` the session's claims rather than anything this
78
+ * client could pass. Only the columns a host legitimately chooses are sent.
79
+ */
80
+ export const createRunDocument = (names) => `
81
+ mutation CreateRun($input: Create${names.runType}Input!) {
82
+ ${names.createRun}(input: $input) {
83
+ ${names.run} {${RUN_FIELDS}
84
+ }
85
+ }
86
+ }`;
87
+ /**
88
+ * Open the thread a run hangs from. `agent_run.thread_id` is required and a
89
+ * local host is the first writer of its own conversation, so it creates one.
90
+ */
91
+ export const createThreadDocument = (names) => `
92
+ mutation CreateThread($input: Create${names.threadType}Input!) {
93
+ ${names.createThread}(input: $input) {
94
+ ${names.thread} {
95
+ id
96
+ title
97
+ createdAt
98
+ }
99
+ }
100
+ }`;
101
+ export const advanceRunDocument = (names) => `
102
+ mutation AdvanceRun($input: Update${names.runType}Input!) {
103
+ ${names.updateRun}(input: $input) {
104
+ ${names.run} {${RUN_FIELDS}
105
+ }
106
+ }
107
+ }`;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The follower a run surface subscribes to: one view per change, until the run
3
+ * is terminal or the caller aborts.
4
+ *
5
+ * This is a loop of its own rather than `@agentic-kit/run-log`'s `follow`
6
+ * because a surface follows two things, not one: the log *and* the run row.
7
+ * Status, the attached execution and the head commit live on the row, and a run
8
+ * that goes idle or finishes emits no event announcing it — so a follower that
9
+ * only tailed events would show a finished run as still running.
10
+ *
11
+ * Catch-up needs no special path. Every read is `seq > cursor`, so a client that
12
+ * was asleep for an hour resumes with the same call it makes when live, and the
13
+ * view merges by `seq` so an overlapping read cannot duplicate a turn.
14
+ */
15
+ import type { RunLogCursor } from '@agentic-kit/run-log';
16
+ import type { GraphqlRunLogClient } from './client';
17
+ import { type RunView } from './view';
18
+ export interface FollowRunOptions {
19
+ cursor?: RunLogCursor;
20
+ /** Delay between reads when no wakeup arrives. */
21
+ pollIntervalMs?: number;
22
+ /** Records per read; a backlog is drained in pages, not in one query. */
23
+ limit?: number;
24
+ /** From `createRealtimeWakeup`, when the app has DataRealtime on the table. */
25
+ waitForChange?: (signal?: AbortSignal) => Promise<void>;
26
+ signal?: AbortSignal;
27
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
28
+ /**
29
+ * Reads after a terminal status, so the tail written as a run finishes is not
30
+ * lost to a race between the last append and the status update.
31
+ */
32
+ drainReads?: number;
33
+ }
34
+ /**
35
+ * Yield a view whenever the run changes. The first yield is the run's whole
36
+ * history, so a caller renders once rather than animating a backlog.
37
+ */
38
+ export declare function followRun(client: GraphqlRunLogClient, runId: string, options?: FollowRunOptions): AsyncGenerator<RunView, void, void>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The follower a run surface subscribes to: one view per change, until the run
3
+ * is terminal or the caller aborts.
4
+ *
5
+ * This is a loop of its own rather than `@agentic-kit/run-log`'s `follow`
6
+ * because a surface follows two things, not one: the log *and* the run row.
7
+ * Status, the attached execution and the head commit live on the row, and a run
8
+ * that goes idle or finishes emits no event announcing it — so a follower that
9
+ * only tailed events would show a finished run as still running.
10
+ *
11
+ * Catch-up needs no special path. Every read is `seq > cursor`, so a client that
12
+ * was asleep for an hour resumes with the same call it makes when live, and the
13
+ * view merges by `seq` so an overlapping read cannot duplicate a turn.
14
+ */
15
+ import { START } from '@agentic-kit/run-log';
16
+ import { applyError, applyRecords, applyRun, emptyRunView, isFollowable, } from './view';
17
+ const DEFAULT_POLL_INTERVAL_MS = 2000;
18
+ const DEFAULT_LIMIT = 200;
19
+ const DEFAULT_DRAIN_READS = 1;
20
+ const defaultSleep = (ms, signal) => new Promise((resolve) => {
21
+ const timer = setTimeout(resolve, ms);
22
+ signal?.addEventListener('abort', () => {
23
+ clearTimeout(timer);
24
+ resolve();
25
+ }, { once: true });
26
+ });
27
+ /**
28
+ * Yield a view whenever the run changes. The first yield is the run's whole
29
+ * history, so a caller renders once rather than animating a backlog.
30
+ */
31
+ export async function* followRun(client, runId, options = {}) {
32
+ const sleep = options.sleep ?? defaultSleep;
33
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
34
+ const limit = options.limit ?? DEFAULT_LIMIT;
35
+ const drainReads = options.drainReads ?? DEFAULT_DRAIN_READS;
36
+ // The formats this client can read are the formats the view can project: a
37
+ // surface following a run written by a second harness renders it with no
38
+ // configuration of its own.
39
+ const projection = { readers: client.readers };
40
+ let view = emptyRunView();
41
+ let cursor = options.cursor ?? START;
42
+ let drainsLeft = drainReads;
43
+ while (!options.signal?.aborted) {
44
+ let batch = [];
45
+ try {
46
+ view = applyRun(view, await client.getRun(runId), projection);
47
+ // Drain the backlog before yielding: a run with a thousand events should
48
+ // render once, not a thousand times.
49
+ for (;;) {
50
+ const page = await client.read(runId, cursor, limit);
51
+ if (page.records.length === 0)
52
+ break;
53
+ cursor = page.cursor;
54
+ batch = batch.concat(page.records);
55
+ if (page.records.length < limit)
56
+ break;
57
+ }
58
+ }
59
+ catch (error) {
60
+ // Yielded, not thrown: a surface that loses the API for a moment should
61
+ // show that it did, and the caller decides whether to abort.
62
+ yield applyError(view, error, projection);
63
+ if (options.signal?.aborted)
64
+ return;
65
+ await sleep(pollIntervalMs, options.signal);
66
+ continue;
67
+ }
68
+ // A successful pass clears a previous failure: the surface is reading again.
69
+ view = applyRecords({ ...view, error: undefined }, batch, projection);
70
+ yield view;
71
+ if (!isFollowable(view)) {
72
+ if (drainsLeft <= 0)
73
+ return;
74
+ drainsLeft -= 1;
75
+ }
76
+ else {
77
+ drainsLeft = drainReads;
78
+ }
79
+ if (options.signal?.aborted)
80
+ return;
81
+ if (options.waitForChange) {
82
+ await Promise.race([
83
+ options.waitForChange(options.signal),
84
+ sleep(pollIntervalMs, options.signal),
85
+ ]);
86
+ }
87
+ else {
88
+ await sleep(pollIntervalMs, options.signal);
89
+ }
90
+ }
91
+ }
package/esm/index.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `@agentic-kit/run-log-client` — the run log as a user's browser sees it.
3
+ *
4
+ * One tenant-GraphQL `RunLogStore` over a module's `agent_run`/`agent_event`
5
+ * pair, a follower that tails the run row alongside the log, and the pure
6
+ * reduction a surface renders. No React, no node builtins: the platform UI, a
7
+ * desktop pane and a test all consume the same three pieces.
8
+ */
9
+ /**
10
+ * The projections and record types a surface renders, re-exported so a host
11
+ * imports one package rather than reaching past this one into the kernel.
12
+ */
13
+ export { ATTACHABLE_STATUSES, createRunLogClient, type CreateRunInput, type CreateThreadInput, GraphqlRunLogClient, isSeqConflict, isTerminalStatus, type ListRunsArgs, type RunLogClientOptions, type RunPatch, type RunStatus, type RunSummary, TERMINAL_STATUSES, type ThreadSummary, } from './client';
14
+ export { advanceRunDocument, appendDocument, createRunDocument, createThreadDocument, EVENT_FIELDS, eventsDocument, RUN_FIELDS, runDocument, runsDocument, } from './documents';
15
+ export { followRun, type FollowRunOptions } from './follow-run';
16
+ export { adoptLocalRun, type LocalRun, markLocalRunFailed, markLocalRunIdle, markLocalRunRunning, openLocalRun, type OpenLocalRunOptions, } from './local-run';
17
+ export { DEFAULT_EVENT_TABLE, DEFAULT_RUN_TABLE, DEFAULT_THREAD_TABLE, type RunLogNames, runLogNames, } from './names';
18
+ export { changedSubscriptionDocument, changedSubscriptionField, createRealtimeWakeup, type RealtimeWakeup, type RealtimeWakeupOptions, type RunLogWsClient, } from './realtime';
19
+ export { isMissingRunSurface } from './surface';
20
+ export { createGraphqlRequest, type GraphqlRequest, type GraphqlTransportOptions, } from './transport';
21
+ export { applyError, applyRecords, applyRun, emptyRunView, isFollowable, mergeRecords, type RunView, type RunViewPhase, type RunViewProjection, } from './view';
22
+ export type { ApprovalResolutionInput, ApprovalState, Conversation, ConversationPart, ModelUsage, RunEventRecord, RunLogCursor, RunLogPage, RunUsage, ToolCallState, ToolCallStatus, ToolStateProjection, } from '@agentic-kit/run-log';
package/esm/index.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `@agentic-kit/run-log-client` — the run log as a user's browser sees it.
3
+ *
4
+ * One tenant-GraphQL `RunLogStore` over a module's `agent_run`/`agent_event`
5
+ * pair, a follower that tails the run row alongside the log, and the pure
6
+ * reduction a surface renders. No React, no node builtins: the platform UI, a
7
+ * desktop pane and a test all consume the same three pieces.
8
+ */
9
+ /**
10
+ * The projections and record types a surface renders, re-exported so a host
11
+ * imports one package rather than reaching past this one into the kernel.
12
+ */
13
+ export { ATTACHABLE_STATUSES, createRunLogClient, GraphqlRunLogClient, isSeqConflict, isTerminalStatus, TERMINAL_STATUSES, } from './client';
14
+ export { advanceRunDocument, appendDocument, createRunDocument, createThreadDocument, EVENT_FIELDS, eventsDocument, RUN_FIELDS, runDocument, runsDocument, } from './documents';
15
+ export { followRun } from './follow-run';
16
+ export { adoptLocalRun, markLocalRunFailed, markLocalRunIdle, markLocalRunRunning, openLocalRun, } from './local-run';
17
+ export { DEFAULT_EVENT_TABLE, DEFAULT_RUN_TABLE, DEFAULT_THREAD_TABLE, runLogNames, } from './names';
18
+ export { changedSubscriptionDocument, changedSubscriptionField, createRealtimeWakeup, } from './realtime';
19
+ export { isMissingRunSurface } from './surface';
20
+ export { createGraphqlRequest, } from './transport';
21
+ export { applyError, applyRecords, applyRun, emptyRunView, isFollowable, mergeRecords, } from './view';
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A run executed by a host on the user's own machine.
3
+ *
4
+ * The cloud lane opens its run on the platform's side — the worker creates the
5
+ * row, then hands the Job a callback lane to append through. A local host has no
6
+ * worker in front of it: it authenticates as the user against the tenant API and
7
+ * so opens its own thread and run, which is why `placement` is stated here
8
+ * rather than defaulted, and why nothing in this file passes an identity. The
9
+ * claims of the request's transaction are the run's owner, and RLS on
10
+ * `agent_run`/`agent_event` is the same visibility rule a cloud run obeys.
11
+ *
12
+ * Kept in this package rather than in the desktop app so the host that opens a
13
+ * local run and the reader the web UI draws it with are the same code.
14
+ */
15
+ import type { GraphqlRunLogClient, RunSummary } from './client';
16
+ export interface OpenLocalRunOptions {
17
+ store: GraphqlRunLogClient;
18
+ /** Title for the thread this run opens; the first prompt makes a good one. */
19
+ title?: string;
20
+ /** An existing thread to append this run to, when the host already has one. */
21
+ threadId?: string;
22
+ repoUrl?: string;
23
+ branch?: string;
24
+ }
25
+ export interface LocalRun {
26
+ runId: string;
27
+ threadId: string;
28
+ }
29
+ /**
30
+ * Open a durable local run: a thread if the host does not already have one, then
31
+ * a `pending` run marked `placement: 'local'`.
32
+ */
33
+ export declare function openLocalRun(options: OpenLocalRunOptions): Promise<LocalRun>;
34
+ /**
35
+ * Adopt a run this host opened earlier — the resume path, and the check that a
36
+ * cached run id still belongs to the signed-in user. Throws when the run is gone
37
+ * or invisible, so a stale cache cannot silently start a second history.
38
+ */
39
+ export declare function adoptLocalRun(store: GraphqlRunLogClient, runId: string): Promise<RunSummary>;
40
+ /** The host has picked the run up and is about to drive the model. */
41
+ export declare function markLocalRunRunning(store: GraphqlRunLogClient, runId: string, now?: Date): Promise<void>;
42
+ /**
43
+ * The turn finished and the host is waiting for the user. `idle` rather than
44
+ * `succeeded` for the same reason a cloud chat run goes idle: the session can be
45
+ * prompted again, and only a closed session is terminal.
46
+ */
47
+ export declare function markLocalRunIdle(store: GraphqlRunLogClient, runId: string): Promise<void>;
48
+ /** The turn failed. The message is recorded on the run before it is rethrown. */
49
+ export declare function markLocalRunFailed(store: GraphqlRunLogClient, runId: string, error: unknown, now?: Date): Promise<void>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A run executed by a host on the user's own machine.
3
+ *
4
+ * The cloud lane opens its run on the platform's side — the worker creates the
5
+ * row, then hands the Job a callback lane to append through. A local host has no
6
+ * worker in front of it: it authenticates as the user against the tenant API and
7
+ * so opens its own thread and run, which is why `placement` is stated here
8
+ * rather than defaulted, and why nothing in this file passes an identity. The
9
+ * claims of the request's transaction are the run's owner, and RLS on
10
+ * `agent_run`/`agent_event` is the same visibility rule a cloud run obeys.
11
+ *
12
+ * Kept in this package rather than in the desktop app so the host that opens a
13
+ * local run and the reader the web UI draws it with are the same code.
14
+ */
15
+ /**
16
+ * Open a durable local run: a thread if the host does not already have one, then
17
+ * a `pending` run marked `placement: 'local'`.
18
+ */
19
+ export async function openLocalRun(options) {
20
+ const { store } = options;
21
+ const threadId = options.threadId ??
22
+ (await store.createThread({ title: options.title, mode: 'agent' })).id;
23
+ const run = await store.createRun({
24
+ threadId,
25
+ placement: 'local',
26
+ status: 'pending',
27
+ ...(options.repoUrl === undefined ? {} : { repoUrl: options.repoUrl }),
28
+ ...(options.branch === undefined ? {} : { branch: options.branch }),
29
+ });
30
+ if (run.placement !== 'local') {
31
+ throw new Error(`run ${run.id} was opened as placement '${run.placement}', not 'local'; the tenant refused the placement this host declared`);
32
+ }
33
+ return { runId: run.id, threadId };
34
+ }
35
+ /**
36
+ * Adopt a run this host opened earlier — the resume path, and the check that a
37
+ * cached run id still belongs to the signed-in user. Throws when the run is gone
38
+ * or invisible, so a stale cache cannot silently start a second history.
39
+ */
40
+ export async function adoptLocalRun(store, runId) {
41
+ const run = await store.getRun(runId);
42
+ if (run.placement !== 'local') {
43
+ throw new Error(`run ${runId} has placement '${run.placement}'; a local host must not take over a cloud run`);
44
+ }
45
+ return run;
46
+ }
47
+ /** The host has picked the run up and is about to drive the model. */
48
+ export async function markLocalRunRunning(store, runId, now = new Date()) {
49
+ await store.updateRun(runId, {
50
+ status: 'running',
51
+ startedAt: now.toISOString(),
52
+ });
53
+ }
54
+ /**
55
+ * The turn finished and the host is waiting for the user. `idle` rather than
56
+ * `succeeded` for the same reason a cloud chat run goes idle: the session can be
57
+ * prompted again, and only a closed session is terminal.
58
+ */
59
+ export async function markLocalRunIdle(store, runId) {
60
+ await store.updateRun(runId, { status: 'idle' });
61
+ }
62
+ /** The turn failed. The message is recorded on the run before it is rethrown. */
63
+ export async function markLocalRunFailed(store, runId, error, now = new Date()) {
64
+ await store.updateRun(runId, {
65
+ status: 'failed',
66
+ error: error instanceof Error ? error.message : String(error),
67
+ finishedAt: now.toISOString(),
68
+ });
69
+ }
package/esm/names.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The GraphQL names a module's run surface is exposed under.
3
+ *
4
+ * The tables are provisioned per agent module (`agent_module.has_runs`), and a
5
+ * module carries its own table names — `agent_run`/`agent_event` at app scope,
6
+ * `org_agent_run`/`org_agent_event` at org scope — so a reader cannot hardcode
7
+ * the fields it queries. Everything here is derived from the two physical names
8
+ * the module loader already reports, using the same inflection the generated
9
+ * SDK targets use: `agent_event` → node `agentEvent`, connection `agentEvents`,
10
+ * mutation `createAgentEvent`, order-by `SEQ_ASC`.
11
+ *
12
+ * Derived rather than injected because the alternative is a table of names per
13
+ * tenant, which drifts the moment a module is provisioned under a new scope.
14
+ */
15
+ export interface RunLogNames {
16
+ /** Physical table name the rest of the names derive from. */
17
+ runTable: string;
18
+ eventTable: string;
19
+ /**
20
+ * The thread a run hangs from. `agent_run.thread_id` is required, so a host
21
+ * that opens its own run — a local one — needs the thread's names too; a host
22
+ * that only reads runs never touches them.
23
+ */
24
+ threadTable: string;
25
+ /** Single-node field, e.g. `agentRun`. */
26
+ run: string;
27
+ event: string;
28
+ thread: string;
29
+ /** Connection field, e.g. `agentRuns`. */
30
+ runs: string;
31
+ events: string;
32
+ threads: string;
33
+ /** Type name, e.g. `AgentRun` — filters and inputs are named after it. */
34
+ runType: string;
35
+ eventType: string;
36
+ threadType: string;
37
+ createEvent: string;
38
+ createRun: string;
39
+ createThread: string;
40
+ updateRun: string;
41
+ /** Patch field on the update input, e.g. `agentRunPatch`. */
42
+ runPatch: string;
43
+ }
44
+ export declare const DEFAULT_RUN_TABLE = "agent_run";
45
+ export declare const DEFAULT_EVENT_TABLE = "agent_event";
46
+ export declare const DEFAULT_THREAD_TABLE = "agent_thread";
47
+ export declare function runLogNames(runTable?: string, eventTable?: string, threadTable?: string): RunLogNames;
package/esm/names.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The GraphQL names a module's run surface is exposed under.
3
+ *
4
+ * The tables are provisioned per agent module (`agent_module.has_runs`), and a
5
+ * module carries its own table names — `agent_run`/`agent_event` at app scope,
6
+ * `org_agent_run`/`org_agent_event` at org scope — so a reader cannot hardcode
7
+ * the fields it queries. Everything here is derived from the two physical names
8
+ * the module loader already reports, using the same inflection the generated
9
+ * SDK targets use: `agent_event` → node `agentEvent`, connection `agentEvents`,
10
+ * mutation `createAgentEvent`, order-by `SEQ_ASC`.
11
+ *
12
+ * Derived rather than injected because the alternative is a table of names per
13
+ * tenant, which drifts the moment a module is provisioned under a new scope.
14
+ */
15
+ const camelize = (snake) => snake
16
+ .split('_')
17
+ .filter((part) => part.length > 0)
18
+ .map((part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))
19
+ .join('');
20
+ const pascalize = (snake) => {
21
+ const camel = camelize(snake);
22
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
23
+ };
24
+ /**
25
+ * Graphile's pluralisation for the shapes a table name can take here. Only the
26
+ * regular cases and the two irregular endings a generated name can hit are
27
+ * handled: a table whose name pluralises irregularly would produce a field this
28
+ * cannot name, and that is a schema this reader must not silently mis-address.
29
+ */
30
+ const pluralize = (word) => {
31
+ if (/(s|x|z|ch|sh)$/.test(word))
32
+ return `${word}es`;
33
+ if (/[^aeiou]y$/.test(word))
34
+ return `${word.slice(0, -1)}ies`;
35
+ return `${word}s`;
36
+ };
37
+ export const DEFAULT_RUN_TABLE = 'agent_run';
38
+ export const DEFAULT_EVENT_TABLE = 'agent_event';
39
+ export const DEFAULT_THREAD_TABLE = 'agent_thread';
40
+ export function runLogNames(runTable = DEFAULT_RUN_TABLE, eventTable = DEFAULT_EVENT_TABLE, threadTable = DEFAULT_THREAD_TABLE) {
41
+ if (!runTable || !eventTable || !threadTable) {
42
+ throw new Error('runLogNames requires the run, event and thread table names; a module without a run surface has none of them');
43
+ }
44
+ const run = camelize(runTable);
45
+ const event = camelize(eventTable);
46
+ const thread = camelize(threadTable);
47
+ const runType = pascalize(runTable);
48
+ const eventType = pascalize(eventTable);
49
+ const threadType = pascalize(threadTable);
50
+ return {
51
+ runTable,
52
+ eventTable,
53
+ threadTable,
54
+ run,
55
+ event,
56
+ thread,
57
+ runs: pluralize(run),
58
+ events: pluralize(event),
59
+ threads: pluralize(thread),
60
+ runType,
61
+ eventType,
62
+ threadType,
63
+ createEvent: `create${eventType}`,
64
+ createRun: `create${runType}`,
65
+ createThread: `create${threadType}`,
66
+ updateRun: `update${runType}`,
67
+ runPatch: `${run}Patch`,
68
+ };
69
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The push wakeup, when the app has one.
3
+ *
4
+ * A run surface is correct without this: `follow` polls, and every read is a
5
+ * cursor read, so nothing is missed by a reader that woke late. What realtime
6
+ * buys is latency, and it costs nothing in correctness — which is why it is a
7
+ * wakeup here rather than a delivery mechanism. Records are never taken from a
8
+ * subscription payload: the notification says *something changed*, the cursor
9
+ * read says *what*, so a dropped, duplicated or out-of-order notification cannot
10
+ * corrupt a transcript, and a reconnecting client catches up by reading rather
11
+ * than by replaying a socket.
12
+ *
13
+ * This is the platform's existing DataRealtime lane (statement-level triggers →
14
+ * `change_log` → the `on<Table>Changed` subscription field), not a new transport.
15
+ * The event table only carries it when the app declares DataRealtime on it, so
16
+ * the subscription is optional everywhere in this package.
17
+ */
18
+ import type { RunLogNames } from './names';
19
+ interface WsSink {
20
+ next(value: {
21
+ data?: unknown;
22
+ errors?: readonly {
23
+ message: string;
24
+ }[];
25
+ }): void;
26
+ error(error: unknown): void;
27
+ complete(): void;
28
+ }
29
+ /** The graphql-ws surface this package uses — supplied by the host, never imported. */
30
+ export interface RunLogWsClient {
31
+ subscribe(payload: {
32
+ query: string;
33
+ variables?: Record<string, unknown>;
34
+ }, sink: WsSink): () => void;
35
+ }
36
+ /**
37
+ * `on<Type>Changed`, the name the platform's codegen gives a DataRealtime table's
38
+ * subscription field. A host that reads the field out of `_meta`
39
+ * (`MetaRealtime.subscriptionFieldName`) can pass it instead.
40
+ */
41
+ export declare const changedSubscriptionField: (names: RunLogNames) => string;
42
+ /**
43
+ * The generated subscription takes no arguments and is per-table, not per-run, so
44
+ * a wakeup may be for somebody else's run — harmless, because the follower's
45
+ * cursor read is what decides whether there is anything new for *this* one.
46
+ */
47
+ export declare const changedSubscriptionDocument: (names: RunLogNames, field?: string) => string;
48
+ export interface RealtimeWakeupOptions {
49
+ client: RunLogWsClient;
50
+ names: RunLogNames;
51
+ /** Override when the host resolves the field name from `_meta` instead. */
52
+ subscriptionField?: string;
53
+ /** Surfaced to the caller; a failed socket must not silently become silence. */
54
+ onError?: (error: Error) => void;
55
+ }
56
+ export interface RealtimeWakeup {
57
+ /** Passed to `follow` as `waitForChange`. */
58
+ waitForChange: (signal?: AbortSignal) => Promise<void>;
59
+ close: () => void;
60
+ }
61
+ /**
62
+ * One subscription, many waits: the socket stays open across follow iterations
63
+ * and each `waitForChange` resolves on the next notification (or immediately, if
64
+ * one arrived while the follower was busy reading — otherwise a change landing
65
+ * mid-read would leave the follower asleep until the poll timer).
66
+ */
67
+ export declare function createRealtimeWakeup(options: RealtimeWakeupOptions): RealtimeWakeup;
68
+ export {};
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The push wakeup, when the app has one.
3
+ *
4
+ * A run surface is correct without this: `follow` polls, and every read is a
5
+ * cursor read, so nothing is missed by a reader that woke late. What realtime
6
+ * buys is latency, and it costs nothing in correctness — which is why it is a
7
+ * wakeup here rather than a delivery mechanism. Records are never taken from a
8
+ * subscription payload: the notification says *something changed*, the cursor
9
+ * read says *what*, so a dropped, duplicated or out-of-order notification cannot
10
+ * corrupt a transcript, and a reconnecting client catches up by reading rather
11
+ * than by replaying a socket.
12
+ *
13
+ * This is the platform's existing DataRealtime lane (statement-level triggers →
14
+ * `change_log` → the `on<Table>Changed` subscription field), not a new transport.
15
+ * The event table only carries it when the app declares DataRealtime on it, so
16
+ * the subscription is optional everywhere in this package.
17
+ */
18
+ /**
19
+ * `on<Type>Changed`, the name the platform's codegen gives a DataRealtime table's
20
+ * subscription field. A host that reads the field out of `_meta`
21
+ * (`MetaRealtime.subscriptionFieldName`) can pass it instead.
22
+ */
23
+ export const changedSubscriptionField = (names) => `on${names.eventType}Changed`;
24
+ /**
25
+ * The generated subscription takes no arguments and is per-table, not per-run, so
26
+ * a wakeup may be for somebody else's run — harmless, because the follower's
27
+ * cursor read is what decides whether there is anything new for *this* one.
28
+ */
29
+ export const changedSubscriptionDocument = (names, field = changedSubscriptionField(names)) => `
30
+ subscription RunEventChanged {
31
+ ${field} {
32
+ event
33
+ ${names.event} { __typename }
34
+ timestamp
35
+ }
36
+ }`;
37
+ /**
38
+ * One subscription, many waits: the socket stays open across follow iterations
39
+ * and each `waitForChange` resolves on the next notification (or immediately, if
40
+ * one arrived while the follower was busy reading — otherwise a change landing
41
+ * mid-read would leave the follower asleep until the poll timer).
42
+ */
43
+ export function createRealtimeWakeup(options) {
44
+ let pendingChange = false;
45
+ let wake = null;
46
+ let closed = false;
47
+ const signalChange = () => {
48
+ pendingChange = true;
49
+ const resume = wake;
50
+ wake = null;
51
+ if (resume)
52
+ resume();
53
+ };
54
+ const unsubscribe = options.client.subscribe({
55
+ query: changedSubscriptionDocument(options.names, options.subscriptionField),
56
+ }, {
57
+ next: (result) => {
58
+ if (result.errors && result.errors.length > 0) {
59
+ options.onError?.(new Error(result.errors.map((e) => e.message).join('; ')));
60
+ return;
61
+ }
62
+ signalChange();
63
+ },
64
+ error: (error) => {
65
+ options.onError?.(error instanceof Error ? error : new Error(String(error)));
66
+ // Wake the follower so it falls back to polling instead of waiting on a
67
+ // socket that is gone.
68
+ signalChange();
69
+ },
70
+ complete: () => signalChange(),
71
+ });
72
+ return {
73
+ waitForChange: (signal) => {
74
+ if (pendingChange || closed) {
75
+ pendingChange = false;
76
+ return Promise.resolve();
77
+ }
78
+ return new Promise((resolve) => {
79
+ wake = resolve;
80
+ signal?.addEventListener('abort', () => resolve(), { once: true });
81
+ });
82
+ },
83
+ close: () => {
84
+ closed = true;
85
+ unsubscribe();
86
+ signalChange();
87
+ },
88
+ };
89
+ }