@north-light/sdk 0.0.0 → 0.3.415

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +187 -2
  3. package/dist/client.d.ts +176 -0
  4. package/dist/client.js +582 -0
  5. package/dist/crtr.d.ts +24 -0
  6. package/dist/crtr.js +50 -0
  7. package/dist/env-command.d.ts +15 -0
  8. package/dist/env-command.js +138 -0
  9. package/dist/error-codes.d.ts +7 -0
  10. package/dist/error-codes.js +43 -0
  11. package/dist/errors.d.ts +72 -0
  12. package/dist/errors.js +101 -0
  13. package/dist/index.d.ts +41 -0
  14. package/dist/index.js +17 -0
  15. package/dist/keygen-cli.d.ts +2 -0
  16. package/dist/keygen-cli.js +11 -0
  17. package/dist/keygen-command.d.ts +6 -0
  18. package/dist/keygen-command.js +131 -0
  19. package/dist/oauth/index.d.ts +235 -0
  20. package/dist/oauth/index.js +621 -0
  21. package/dist/oauth/keygen.d.ts +18 -0
  22. package/dist/oauth/keygen.js +46 -0
  23. package/dist/oauth/refusal.d.ts +32 -0
  24. package/dist/oauth/refusal.js +17 -0
  25. package/dist/resources/activity.d.ts +22 -0
  26. package/dist/resources/activity.js +43 -0
  27. package/dist/resources/attachments.d.ts +15 -0
  28. package/dist/resources/attachments.js +13 -0
  29. package/dist/resources/bash.d.ts +9 -0
  30. package/dist/resources/bash.js +15 -0
  31. package/dist/resources/canvas/history.d.ts +11 -0
  32. package/dist/resources/canvas/history.js +20 -0
  33. package/dist/resources/canvas.d.ts +19 -0
  34. package/dist/resources/canvas.js +42 -0
  35. package/dist/resources/crons.d.ts +15 -0
  36. package/dist/resources/crons.js +33 -0
  37. package/dist/resources/custom-objects.d.ts +20 -0
  38. package/dist/resources/custom-objects.js +84 -0
  39. package/dist/resources/files.d.ts +34 -0
  40. package/dist/resources/files.js +34 -0
  41. package/dist/resources/follower.d.ts +71 -0
  42. package/dist/resources/follower.js +202 -0
  43. package/dist/resources/forward.d.ts +7 -0
  44. package/dist/resources/forward.js +67 -0
  45. package/dist/resources/grant-watcher.d.ts +57 -0
  46. package/dist/resources/grant-watcher.js +100 -0
  47. package/dist/resources/human/inbox.d.ts +14 -0
  48. package/dist/resources/human/inbox.js +30 -0
  49. package/dist/resources/human/requests.d.ts +13 -0
  50. package/dist/resources/human/requests.js +26 -0
  51. package/dist/resources/human.d.ts +8 -0
  52. package/dist/resources/human.js +10 -0
  53. package/dist/resources/identifiers.d.ts +8 -0
  54. package/dist/resources/identifiers.js +33 -0
  55. package/dist/resources/memory.d.ts +69 -0
  56. package/dist/resources/memory.js +30 -0
  57. package/dist/resources/models/config.d.ts +8 -0
  58. package/dist/resources/models/config.js +9 -0
  59. package/dist/resources/models/credentials.d.ts +10 -0
  60. package/dist/resources/models/credentials.js +17 -0
  61. package/dist/resources/models.d.ts +8 -0
  62. package/dist/resources/models.js +10 -0
  63. package/dist/resources/node-stream.d.ts +34 -0
  64. package/dist/resources/node-stream.js +176 -0
  65. package/dist/resources/nodes/jobs.d.ts +9 -0
  66. package/dist/resources/nodes/jobs.js +14 -0
  67. package/dist/resources/nodes/result.d.ts +8 -0
  68. package/dist/resources/nodes/result.js +11 -0
  69. package/dist/resources/nodes/worktree.d.ts +9 -0
  70. package/dist/resources/nodes/worktree.js +14 -0
  71. package/dist/resources/nodes.d.ts +23 -0
  72. package/dist/resources/nodes.js +47 -0
  73. package/dist/resources/providers.d.ts +51 -0
  74. package/dist/resources/providers.js +15 -0
  75. package/dist/resources/questions.d.ts +49 -0
  76. package/dist/resources/questions.js +21 -0
  77. package/dist/resources/request.d.ts +12 -0
  78. package/dist/resources/request.js +50 -0
  79. package/dist/resources/run-notice.d.ts +34 -0
  80. package/dist/resources/run-notice.js +67 -0
  81. package/dist/resources/run-reply.d.ts +79 -0
  82. package/dist/resources/run-reply.js +93 -0
  83. package/dist/resources/run-stream.d.ts +65 -0
  84. package/dist/resources/run-stream.js +243 -0
  85. package/dist/resources/run-watcher.d.ts +98 -0
  86. package/dist/resources/run-watcher.js +162 -0
  87. package/dist/resources/runs.d.ts +199 -0
  88. package/dist/resources/runs.js +228 -0
  89. package/dist/resources/shares.d.ts +40 -0
  90. package/dist/resources/shares.js +19 -0
  91. package/dist/resources/structured-requests.d.ts +14 -0
  92. package/dist/resources/structured-requests.js +19 -0
  93. package/dist/resources/uploads.d.ts +27 -0
  94. package/dist/resources/uploads.js +11 -0
  95. package/dist/schema.d.ts +7 -0
  96. package/dist/schema.js +10 -0
  97. package/dist/stores/postgres.d.ts +32 -0
  98. package/dist/stores/postgres.js +92 -0
  99. package/dist/testing/clock.d.ts +7 -0
  100. package/dist/testing/clock.js +12 -0
  101. package/dist/testing/cloud.d.ts +228 -0
  102. package/dist/testing/cloud.js +914 -0
  103. package/dist/testing/conformance/index.d.ts +2 -0
  104. package/dist/testing/conformance/index.js +3 -0
  105. package/dist/testing/conformance/keyed-write.d.ts +43 -0
  106. package/dist/testing/conformance/keyed-write.js +124 -0
  107. package/dist/testing/index.d.ts +7 -0
  108. package/dist/testing/index.js +4 -0
  109. package/dist/testing/notices.d.ts +76 -0
  110. package/dist/testing/notices.js +209 -0
  111. package/dist/testing/run.d.ts +174 -0
  112. package/dist/testing/run.js +429 -0
  113. package/dist/testing/users.d.ts +127 -0
  114. package/dist/testing/users.js +78 -0
  115. package/dist/testing/wire.d.ts +62 -0
  116. package/dist/testing/wire.js +148 -0
  117. package/dist/types.d.ts +88 -0
  118. package/dist/types.js +1 -0
  119. package/dist/version.d.ts +2 -0
  120. package/dist/version.js +3 -0
  121. package/package.json +60 -5
@@ -0,0 +1,228 @@
1
+ import { sleepWithin } from '@north-light/api';
2
+ import { APIError, mapError } from '../errors.js';
3
+ import { RunStream } from './run-stream.js';
4
+ import { RunReply } from './run-reply.js';
5
+ import { resolveJsonSchema } from '../schema.js';
6
+ import { deadline, heldReadOptions } from './request.js';
7
+ export class Runs {
8
+ request;
9
+ openEvents;
10
+ questions;
11
+ constructor(request, openEvents, questions) {
12
+ this.request = request;
13
+ this.openEvents = openEvents;
14
+ this.questions = questions;
15
+ }
16
+ async start(params, options = {}) {
17
+ const { idempotencyKey = crypto.randomUUID(), maxRetries = 2, ...rest } = options;
18
+ const body = params.output_schema === undefined ? params : { ...params, output_schema: resolveJsonSchema(params.output_schema) };
19
+ return this.keyedSend('/v1/runs', body, idempotencyKey, maxRetries, rest);
20
+ }
21
+ get(runId, options = {}) {
22
+ const { wait, ...rest } = options;
23
+ return this.request('GET', `${runPath(runId)}${query({ wait })}`, undefined, heldReadOptions(wait, rest));
24
+ }
25
+ list(filters = {}, options) {
26
+ return this.request('GET', `/v1/runs${query(filters)}`, undefined, options);
27
+ }
28
+ /** Every run across pages, most recently active first: `for await (const run of runs.listAll())`. Fetches
29
+ * the next page (`limit` per page) only as you iterate; `break` stops fetching. */
30
+ async *listAll(filters = {}, options) {
31
+ for (let after;;) {
32
+ const page = await this.list({ ...filters, ...(after === undefined ? {} : { after }) }, options);
33
+ yield* page.runs;
34
+ if (!page.next)
35
+ return;
36
+ after = page.next;
37
+ }
38
+ }
39
+ /** Send a message to a run. `start_turn: true` (the default) answers it in its
40
+ * own turn: a running run receives it when its current turn ends, and a
41
+ * message sent before the first turn has started queues behind that turn,
42
+ * because a run's `runs.start` prompt is always its first turn's input.
43
+ * `start_turn: false` stores it without starting a turn; it is delivered as
44
+ * context at the start of the next turn, ahead of that turn's input. For a
45
+ * new run that is the first turn only if it is stored before the run claims
46
+ * its first turn's messages; to guarantee it, pass `message` to `runs.start`.
47
+ *
48
+ * The send carries an `Idempotency-Key`: `idempotencyKey` when given, otherwise a UUID generated for this
49
+ * call and reused on every retry of it. The daemon keeps the key for 24 hours; a repeat with the same key
50
+ * and the same message returns the first send's result without delivering again, and the same key with a
51
+ * different message or `start_turn` throws `ConflictError` `idempotency_conflict`. To make a send safe
52
+ * across your own restarts, derive the key from your own record of the send (a batch or row id). */
53
+ async message(runId, message, options = {}) {
54
+ const { idempotencyKey = crypto.randomUUID(), maxRetries = 2, start_turn, ...rest } = options;
55
+ const body = { message, ...(start_turn === undefined ? {} : { start_turn }) };
56
+ // One key per logical send, reused on every retry: the daemon answers a repeat with the first send's
57
+ // result, so a retry after a lost response cannot deliver the message twice.
58
+ return this.keyedSend(`${runPath(runId)}/messages`, body, idempotencyKey, maxRetries, rest);
59
+ }
60
+ /** A keyed POST under one overall deadline. `budgetMs` / `deadlineAt` (fixed here, once) bound every attempt, every
61
+ * backoff and every `retry_after_s` wait together, so the call never outlasts its deadline: an application lease
62
+ * taken for the call needs only to exceed that deadline. A server `retry_after_s` is honored only when it fits the
63
+ * time left; otherwise the call rejects at once with `DeadlineExceededError` (`reason: 'retry_after'`), carrying
64
+ * the refusal as `cause`, rather than sleeping past the deadline. A send without a deadline keeps the capped
65
+ * backoff and honors any `retry_after_s`. */
66
+ async keyedSend(path, body, idempotencyKey, maxRetries, rest) {
67
+ const options = deadline(rest) ?? rest;
68
+ for (let attempt = 0;; attempt++) {
69
+ try {
70
+ return await this.request('POST', path, body, { ...options, maxRetries: 0, headers: { ...options.headers, 'Idempotency-Key': idempotencyKey } });
71
+ }
72
+ catch (error) {
73
+ if (attempt >= maxRetries || !(error instanceof APIError) || !retryableSend(error))
74
+ throw error;
75
+ // The server's wait is honored only against a deadline (sleepWithin refuses a wait longer than the time
76
+ // left); with none, the plain backoff applies and a Retry-After is waited as before.
77
+ const waitS = error.retryAfterS;
78
+ try {
79
+ if (options.deadlineAt === undefined)
80
+ await pause(waitS ? waitS * 1_000 : 500 * 2 ** attempt, options.signal);
81
+ else
82
+ await sleepWithin(500 * 2 ** attempt, { deadlineAt: options.deadlineAt, signal: options.signal }, waitS, error);
83
+ }
84
+ catch (stop) {
85
+ // An abort during the wait rejects with the signal's own reason, as `pause` does.
86
+ if (options.signal?.aborted)
87
+ throw options.signal.reason;
88
+ throw stop instanceof APIError ? mapError(stop) : stop;
89
+ }
90
+ }
91
+ }
92
+ }
93
+ interrupt(runId, options = {}) {
94
+ const { children, ...rest } = options;
95
+ return this.request('POST', `${runPath(runId)}/interrupt`, children === undefined ? {} : { children }, rest);
96
+ }
97
+ cancel(runId, options = {}) {
98
+ const { keepSchedules, ...rest } = options;
99
+ return this.request('POST', `${runPath(runId)}/cancel`, keepSchedules === undefined ? {} : { keep_schedules: keepSchedules }, rest);
100
+ }
101
+ /** Delete the run, running or not: the runtime stops its turns and sandboxes itself, so there is no need to
102
+ * `cancel` first. Deleting a run that is already gone throws `NotFoundError` (404).
103
+ * `uploads_pending` counts the run's released uploads storage has not yet confirmed deleted; the runtime retries those. */
104
+ delete(runId, options) {
105
+ return this.request('DELETE', runPath(runId), undefined, options);
106
+ }
107
+ /** Rename the caller's own run. The name is trimmed, must not be empty, and is at most 200 characters. */
108
+ rename(runId, name, options) {
109
+ return this.request('POST', `${runPath(runId)}/rename`, { name }, options);
110
+ }
111
+ trace(runId, options = {}) {
112
+ const { format, ...rest } = options;
113
+ return this.request('GET', `${runPath(runId)}/trace${query({ format })}`, undefined, rest);
114
+ }
115
+ events(runId, options = {}) {
116
+ return new RunStream((after, signal) => this.openEvents(runId, after, signal), options.after, options.signal, options.onReconnect);
117
+ }
118
+ /** Follow the reply to one message: `sent` is what `runs.message` returned, or `runs.start`'s `message`. Reads the run's events after
119
+ * `sent.sequence_number`, keeps the root's, starts at the turn that delivers the message and ends after that
120
+ * turn's `turn.completed` (or the run's settlement). Several messages delivered in one turn share that turn's reply.
121
+ * An expired cursor throws `stream_gap`. */
122
+ reply(runId, sent, options = {}) {
123
+ if (sent.run_id !== runId)
124
+ throw new TypeError('sent belongs to a different run');
125
+ return new RunReply((after) => this.events(runId, { after, signal: options.signal }), sent);
126
+ }
127
+ stream(params, options = {}) {
128
+ const controller = new AbortController();
129
+ options.signal?.addEventListener('abort', () => controller.abort(), { once: true });
130
+ const started = this.start(params, options);
131
+ return new RunStream(async (after, signal) => {
132
+ const run = await started;
133
+ return this.openEvents(run.run_id, after, signal);
134
+ }, 0, controller.signal);
135
+ }
136
+ /** Long-polls `runs.get` until the run is `settled`, `waiting_on_user`, or `idle`, and returns that response
137
+ * unchanged (`result` is not parsed). Aborting `signal` rejects with its reason and leaves the run as it is. */
138
+ async wait(runId, options = {}) {
139
+ // One deadline for the whole wait: every poll shares it instead of restarting `budgetMs`.
140
+ const bounded = deadline(options) ?? options;
141
+ return abortable(bounded.signal, async () => {
142
+ for (;;) {
143
+ const result = await this.get(runId, { ...bounded, wait: 25 });
144
+ if (result.status === 'settled' || result.status === 'waiting_on_user' || result.status === 'idle')
145
+ return result;
146
+ }
147
+ });
148
+ }
149
+ /** Start a run with `output_schema` and wait for it to settle; resolves with the outcome and `output_parsed`
150
+ * typed to the schema. `onQuestion` answers each open question once; without it, a question rejects with
151
+ * `APIError` code `waiting_on_user`. A run that stops without settling rejects `run_idle` (untouched:
152
+ * send `runs.message` or `runs.cancel`). Aborting `signal` rejects with its reason; the run is never canceled. */
153
+ async parse(params, options = {}) {
154
+ const { onQuestion, idempotencyKey, ...unbounded } = options;
155
+ // One deadline for the start, every poll and every answer.
156
+ const rest = deadline(unbounded) ?? unbounded;
157
+ return abortable(rest.signal, () => this.parseRun(params, onQuestion, idempotencyKey, rest));
158
+ }
159
+ async parseRun(params, onQuestion, idempotencyKey, rest) {
160
+ const run = await this.start(params, { ...rest, ...(idempotencyKey === undefined ? {} : { idempotencyKey }) });
161
+ const answered = new Set();
162
+ for (;;) {
163
+ const current = await this.wait(run.run_id, rest);
164
+ if (current.status === 'settled' && current.outcome) {
165
+ const outcome = current.outcome;
166
+ if (outcome.kind === 'result')
167
+ return { ...outcome, output_parsed: current.result };
168
+ return { ...outcome, output_parsed: null };
169
+ }
170
+ if (current.status === 'idle') {
171
+ throw new APIError(0, 'run_idle', 'the run stopped without settling; send runs.message or runs.cancel', undefined, undefined, { retryable: false, run_id: run.run_id });
172
+ }
173
+ const open = current.pending_question_ids.filter((id) => !answered.has(id));
174
+ if (!onQuestion) {
175
+ const questionId = open[0] ?? current.pending_question_ids[0];
176
+ throw new APIError(0, 'waiting_on_user', 'the run is waiting on a question and no onQuestion was given', undefined, undefined, { retryable: false, run_id: run.run_id, ...(questionId === undefined ? {} : { question_id: questionId }) });
177
+ }
178
+ if (!this.questions)
179
+ throw new TypeError('runs.parse needs the client questions resource to answer a question');
180
+ for (const id of open) {
181
+ const question = await this.questions.get(id, rest);
182
+ const responses = await onQuestion(question);
183
+ answered.add(id);
184
+ await this.questions.answer(id, responses, rest);
185
+ }
186
+ // Every listed question already answered: the run has not moved on yet, so poll again after a pause.
187
+ if (open.length === 0)
188
+ await pause(500, rest.signal);
189
+ }
190
+ }
191
+ }
192
+ /** A keyed send is safe to repeat after a refusal the daemon marked retryable or a transport failure. */
193
+ function retryableSend(error) {
194
+ return error.retryable === true || error.code === 'transport_error' || error.code === 'daemon_request_interrupted' || error.code === 'connection_error' || error.code === 'request_timeout';
195
+ }
196
+ function runPath(id) {
197
+ if (!/^[A-Za-z0-9_-]+$/.test(id))
198
+ throw new TypeError('Invalid run id');
199
+ return `/v1/runs/${encodeURIComponent(id)}`;
200
+ }
201
+ function query(fields) {
202
+ const params = new URLSearchParams();
203
+ for (const [key, value] of Object.entries(fields))
204
+ if (value !== undefined)
205
+ params.set(key, String(value));
206
+ return params.size ? `?${params}` : '';
207
+ }
208
+ /** SDK-RUN-27: an aborted helper rejects with the signal's own reason, whatever the in-flight request raised. */
209
+ async function abortable(signal, run) {
210
+ if (signal?.aborted)
211
+ throw signal.reason;
212
+ try {
213
+ return await run();
214
+ }
215
+ catch (error) {
216
+ if (signal?.aborted)
217
+ throw signal.reason;
218
+ throw error;
219
+ }
220
+ }
221
+ async function pause(ms, signal) {
222
+ if (signal?.aborted)
223
+ throw signal.reason;
224
+ await new Promise((resolve, reject) => {
225
+ const timer = setTimeout(resolve, ms);
226
+ signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason); }, { once: true });
227
+ });
228
+ }
@@ -0,0 +1,40 @@
1
+ import type { RequestOptions } from '../types.js';
2
+ import type { Request } from './request.js';
3
+ /** `shares.create` input. */
4
+ export interface ShareCreateParams {
5
+ /** Absolute path of a file the caller may read. Without `crtr:files:share`, only files in the caller's own app space. */
6
+ path: string;
7
+ /** ISO 8601 time in the future; absent or null keeps the share until it is deleted. */
8
+ expires_at?: string | null;
9
+ }
10
+ /** A newly created share: a frozen copy of the file at a public URL. */
11
+ export interface ShareCreated {
12
+ share_id: string;
13
+ url: string;
14
+ expires_at: string | null;
15
+ }
16
+ /** A share the caller created. */
17
+ export interface Share {
18
+ share_id: string;
19
+ url: string;
20
+ source_path: string;
21
+ created_by: string;
22
+ created_at: string;
23
+ expires_at: string | null;
24
+ status: 'on' | 'off';
25
+ }
26
+ /** Public share links (app listener only). List and delete act on the caller's own shares. */
27
+ export declare class Shares {
28
+ private readonly request;
29
+ constructor(request: Request);
30
+ /** Never retried: a retry could create a second share. */
31
+ create(params: ShareCreateParams, options?: RequestOptions): Promise<ShareCreated>;
32
+ list(options?: RequestOptions): Promise<{
33
+ shares: Share[];
34
+ }>;
35
+ /** Turn a share off and delete its stored copy. */
36
+ delete(shareId: string, options?: RequestOptions): Promise<{
37
+ share_id: string;
38
+ status: 'off';
39
+ }>;
40
+ }
@@ -0,0 +1,19 @@
1
+ import { pathSegment } from './identifiers.js';
2
+ /** Public share links (app listener only). List and delete act on the caller's own shares. */
3
+ export class Shares {
4
+ request;
5
+ constructor(request) {
6
+ this.request = request;
7
+ }
8
+ /** Never retried: a retry could create a second share. */
9
+ create(params, options = {}) {
10
+ return this.request('POST', '/v1/shares', params, { ...options, maxRetries: 0 });
11
+ }
12
+ list(options) {
13
+ return this.request('GET', '/v1/shares', undefined, options);
14
+ }
15
+ /** Turn a share off and delete its stored copy. */
16
+ delete(shareId, options) {
17
+ return this.request('DELETE', `/v1/shares/${encodeURIComponent(pathSegment(shareId, 'share id'))}`, undefined, options);
18
+ }
19
+ }
@@ -0,0 +1,14 @@
1
+ import type { CreateStructuredRequest, CreateStructuredRequestResponse, StructuredRequestDTO } from '@north-light/api';
2
+ import type { RequestOptions } from '../types.js';
3
+ import type { Request } from './request.js';
4
+ /** Durable structured requests: a schema-bound question to a new or existing node, settled once. */
5
+ export declare class StructuredRequests {
6
+ private readonly request;
7
+ constructor(request: Request);
8
+ create(body: CreateStructuredRequest, options?: RequestOptions): Promise<CreateStructuredRequestResponse>;
9
+ /** `wait` (0–25 seconds) long-polls until the request settles. */
10
+ retrieve(requestId: string, query?: {
11
+ wait?: number;
12
+ }, options?: RequestOptions): Promise<StructuredRequestDTO>;
13
+ cancel(requestId: string, options?: RequestOptions): Promise<StructuredRequestDTO>;
14
+ }
@@ -0,0 +1,19 @@
1
+ import { pathSegment } from './identifiers.js';
2
+ import { withQuery } from './request.js';
3
+ /** Durable structured requests: a schema-bound question to a new or existing node, settled once. */
4
+ export class StructuredRequests {
5
+ request;
6
+ constructor(request) {
7
+ this.request = request;
8
+ }
9
+ create(body, options) {
10
+ return this.request('POST', '/v1/structured-requests', body, options);
11
+ }
12
+ /** `wait` (0–25 seconds) long-polls until the request settles. */
13
+ retrieve(requestId, query, options) {
14
+ return this.request('GET', withQuery(`/v1/structured-requests/${pathSegment(requestId, 'request id')}`, query), undefined, options);
15
+ }
16
+ cancel(requestId, options) {
17
+ return this.request('POST', `/v1/structured-requests/${pathSegment(requestId, 'request id')}/cancel`, {}, options);
18
+ }
19
+ }
@@ -0,0 +1,27 @@
1
+ import type { RequestOptions } from '../types.js';
2
+ import type { Request } from './request.js';
3
+ /** `uploads.create` input: the file the caller will PUT to `upload_link`. At most 100 MiB. */
4
+ export interface UploadCreateParams {
5
+ /** File name, stored as the last segment of `url`. */
6
+ name: string;
7
+ /** The media type the bytes are PUT with. */
8
+ media_type: string;
9
+ /** Exact byte length, a non-negative integer. */
10
+ size: number;
11
+ }
12
+ /** A reserved upload. PUT the bytes to `upload_link` before `expires_at`, then put `url` in a run prompt or message. */
13
+ export interface Upload {
14
+ upload_id: string;
15
+ /** The storage URL a run's prompt or message names; the runtime fetches it into the run's `attachments/`. */
16
+ url: string;
17
+ /** Signed PUT link for the bytes. */
18
+ upload_link: string;
19
+ expires_at: string;
20
+ }
21
+ /** Uploads to the runtime's storage (app listener only). */
22
+ export declare class Uploads {
23
+ private readonly request;
24
+ constructor(request: Request);
25
+ /** Reserve an upload. Never retried: a retry would reserve a second upload. */
26
+ create(params: UploadCreateParams, options?: RequestOptions): Promise<Upload>;
27
+ }
@@ -0,0 +1,11 @@
1
+ /** Uploads to the runtime's storage (app listener only). */
2
+ export class Uploads {
3
+ request;
4
+ constructor(request) {
5
+ this.request = request;
6
+ }
7
+ /** Reserve an upload. Never retried: a retry would reserve a second upload. */
8
+ create(params, options = {}) {
9
+ return this.request('POST', '/v1/uploads', params, { ...options, maxRetries: 0 });
10
+ }
11
+ }
@@ -0,0 +1,7 @@
1
+ import type { JsonSchema } from './types.js';
2
+ /** Accept a plain JSON Schema object or anything with `toJSONSchema()` (a zod
3
+ * v4 object satisfies this directly) and resolve it to the plain object the
4
+ * wire format (`output_schema`, a JSON string) needs. */
5
+ export declare function resolveJsonSchema(schema: JsonSchema | {
6
+ toJSONSchema(): JsonSchema;
7
+ }): JsonSchema;
package/dist/schema.js ADDED
@@ -0,0 +1,10 @@
1
+ /** Accept a plain JSON Schema object or anything with `toJSONSchema()` (a zod
2
+ * v4 object satisfies this directly) and resolve it to the plain object the
3
+ * wire format (`output_schema`, a JSON string) needs. */
4
+ export function resolveJsonSchema(schema) {
5
+ const withMethod = schema;
6
+ if (typeof withMethod.toJSONSchema === 'function') {
7
+ return schema.toJSONSchema();
8
+ }
9
+ return schema;
10
+ }
@@ -0,0 +1,32 @@
1
+ import type { Connection, ConnectionStore } from '../oauth/index.js';
2
+ interface QueryResult {
3
+ rows: Record<string, unknown>[];
4
+ }
5
+ interface QueryClient {
6
+ query(sql: string, values?: unknown[]): Promise<QueryResult>;
7
+ release(): void;
8
+ }
9
+ export interface PostgresPool {
10
+ query(sql: string, values?: unknown[]): Promise<QueryResult>;
11
+ connect(): Promise<QueryClient>;
12
+ }
13
+ export interface PostgresConnectionStoreOptions {
14
+ /** Unquoted PostgreSQL identifier, optionally schema-qualified. Default: connection. */
15
+ table?: string;
16
+ }
17
+ /** A shared PostgreSQL connection store with a transaction-scoped, per-user advisory lock. */
18
+ export declare class PostgresConnectionStore implements ConnectionStore {
19
+ private readonly pool;
20
+ private readonly transaction;
21
+ private readonly table;
22
+ constructor(pool: PostgresPool, options?: PostgresConnectionStoreOptions);
23
+ lock<T>(userId: string, fn: () => Promise<T>): Promise<T>;
24
+ load(userId: string): Promise<Connection | null>;
25
+ save(connection: Connection): Promise<void>;
26
+ /** Delete `userId`'s row only while it still holds `refreshToken`; answers whether one was deleted. Inside
27
+ * `lock` it runs in that transaction, so it commits when the locked callback resolves. */
28
+ remove(userId: string, refreshToken: string): Promise<boolean>;
29
+ /** Connections not saved since `before`, oldest first. Save activity is not necessarily refresh activity. */
30
+ listStale(before: Date): Promise<Connection[]>;
31
+ }
32
+ export {};
@@ -0,0 +1,92 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ function tableName(name) {
3
+ const parts = name.split('.');
4
+ if (parts.length < 1 || parts.length > 2 || parts.some((part) => !/^[a-z_][a-z_0-9]*$/.test(part))) {
5
+ throw new TypeError('table must be a lowercase PostgreSQL identifier, optionally schema-qualified');
6
+ }
7
+ return parts.map((part) => `"${part}"`).join('.');
8
+ }
9
+ function toConnection(row) {
10
+ const expiry = row.access_token_expires_at;
11
+ if (!(expiry instanceof Date) || !Number.isFinite(expiry.getTime())) {
12
+ throw new TypeError('access_token_expires_at must be a PostgreSQL timestamptz (pg Date)');
13
+ }
14
+ return {
15
+ userId: row.user_id,
16
+ runtimeUrl: row.runtime_url,
17
+ refreshToken: row.refresh_token,
18
+ grantId: row.grant_id,
19
+ accessToken: row.access_token,
20
+ accessTokenExpiresAt: expiry.getTime(),
21
+ ...(row.id_token == null ? {} : { idToken: row.id_token }),
22
+ };
23
+ }
24
+ /** A shared PostgreSQL connection store with a transaction-scoped, per-user advisory lock. */
25
+ export class PostgresConnectionStore {
26
+ pool;
27
+ transaction = new AsyncLocalStorage();
28
+ table;
29
+ constructor(pool, options = {}) {
30
+ this.pool = pool;
31
+ this.table = tableName(options.table ?? 'connection');
32
+ }
33
+ async lock(userId, fn) {
34
+ const current = this.transaction.getStore();
35
+ if (current) {
36
+ await current.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [userId]);
37
+ return fn();
38
+ }
39
+ const db = await this.pool.connect();
40
+ let begun = false;
41
+ try {
42
+ await db.query('BEGIN');
43
+ begun = true;
44
+ await db.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [userId]);
45
+ const result = await this.transaction.run(db, fn);
46
+ await db.query('COMMIT');
47
+ begun = false;
48
+ return result;
49
+ }
50
+ catch (error) {
51
+ if (begun) {
52
+ try {
53
+ await db.query('ROLLBACK');
54
+ }
55
+ catch { /* Preserve the original failure. */ }
56
+ }
57
+ throw error;
58
+ }
59
+ finally {
60
+ db.release();
61
+ }
62
+ }
63
+ async load(userId) {
64
+ const result = await (this.transaction.getStore() ?? this.pool).query(`SELECT user_id, runtime_url, refresh_token, grant_id, access_token, access_token_expires_at, id_token FROM ${this.table} WHERE user_id = $1`, [userId]);
65
+ return result.rows[0] ? toConnection(result.rows[0]) : null;
66
+ }
67
+ async save(connection) {
68
+ const expiry = new Date(connection.accessTokenExpiresAt);
69
+ if (!Number.isFinite(expiry.getTime()))
70
+ throw new TypeError('accessTokenExpiresAt must be a valid timestamp');
71
+ await (this.transaction.getStore() ?? this.pool).query(`INSERT INTO ${this.table} (user_id, runtime_url, refresh_token, grant_id, access_token, access_token_expires_at, id_token, updated_at)
72
+ VALUES ($1, $2, $3, $4, $5, $6, $7, clock_timestamp())
73
+ ON CONFLICT (user_id) DO UPDATE SET runtime_url = EXCLUDED.runtime_url, refresh_token = EXCLUDED.refresh_token,
74
+ grant_id = EXCLUDED.grant_id, access_token = EXCLUDED.access_token,
75
+ access_token_expires_at = EXCLUDED.access_token_expires_at, id_token = EXCLUDED.id_token, updated_at = clock_timestamp()`, [connection.userId, connection.runtimeUrl, connection.refreshToken, connection.grantId,
76
+ connection.accessToken, expiry, connection.idToken ?? null]);
77
+ }
78
+ /** Delete `userId`'s row only while it still holds `refreshToken`; answers whether one was deleted. Inside
79
+ * `lock` it runs in that transaction, so it commits when the locked callback resolves. */
80
+ async remove(userId, refreshToken) {
81
+ const result = await (this.transaction.getStore() ?? this.pool).query(`DELETE FROM ${this.table} WHERE user_id = $1 AND refresh_token = $2 RETURNING user_id`, [userId, refreshToken]);
82
+ return result.rows.length > 0;
83
+ }
84
+ /** Connections not saved since `before`, oldest first. Save activity is not necessarily refresh activity. */
85
+ async listStale(before) {
86
+ if (!Number.isFinite(before.getTime()))
87
+ throw new TypeError('before must be a valid Date');
88
+ const result = await (this.transaction.getStore() ?? this.pool).query(`SELECT user_id, runtime_url, refresh_token, grant_id, access_token, access_token_expires_at, id_token
89
+ FROM ${this.table} WHERE updated_at < $1 ORDER BY updated_at ASC, user_id ASC`, [before]);
90
+ return result.rows.map(toConnection);
91
+ }
92
+ }
@@ -0,0 +1,7 @@
1
+ /** A clock a test steps by hand: `now()` returns the same instant until `advance(ms)` moves it. */
2
+ export interface ManualClock {
3
+ now(): number;
4
+ advance(ms: number): void;
5
+ }
6
+ /** A `ManualClock` starting at `startMs` (default 2026-01-01T00:00:00Z). Pass it to `FakeCloud` as `clock`. */
7
+ export declare function manualClock(startMs?: number): ManualClock;
@@ -0,0 +1,12 @@
1
+ /** A `ManualClock` starting at `startMs` (default 2026-01-01T00:00:00Z). Pass it to `FakeCloud` as `clock`. */
2
+ export function manualClock(startMs = Date.UTC(2026, 0, 1)) {
3
+ let current = startMs;
4
+ return {
5
+ now: () => current,
6
+ advance(ms) {
7
+ if (!Number.isFinite(ms) || ms < 0)
8
+ throw new RangeError(`advance needs a non-negative number of ms, got ${ms}`);
9
+ current += ms;
10
+ },
11
+ };
12
+ }