@k9o/core 1.1.1 → 1.3.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.
- package/CHANGELOG.md +49 -0
- package/README.md +169 -16
- package/dist/{chunk-O2ZTV6YN.js → chunk-KC4EDMBF.js} +61 -3
- package/dist/chunk-KC4EDMBF.js.map +1 -0
- package/dist/{chunk-2NDFTHUB.js → chunk-UL53GNYM.js} +23 -5
- package/dist/chunk-UL53GNYM.js.map +1 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +2 -1
- package/dist/index.d.ts +563 -517
- package/dist/index.js +170 -421
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +3 -3
- package/dist/url-grammar.d.ts +32 -16
- package/dist/url-grammar.js +5 -1
- package/package.json +4 -4
- package/src/audit.ts +16 -26
- package/src/compute/llms.ts +55 -33
- package/src/functions.ts +60 -27
- package/src/http.ts +5 -1
- package/src/index.ts +16 -17
- package/src/me.ts +69 -85
- package/src/migrations.ts +61 -11
- package/src/org.ts +138 -140
- package/src/orgs.ts +16 -3
- package/src/policies.ts +63 -24
- package/src/projects.ts +58 -22
- package/src/realtime.ts +21 -200
- package/src/region.ts +83 -3
- package/src/storage-buckets.ts +17 -5
- package/src/studio/workflows.ts +8 -8
- package/src/types.ts +3 -3
- package/src/url-grammar.ts +52 -25
- package/dist/chunk-2NDFTHUB.js.map +0 -1
- package/dist/chunk-O2ZTV6YN.js.map +0 -1
- package/src/metadata.ts +0 -130
package/src/projects.ts
CHANGED
|
@@ -5,51 +5,87 @@
|
|
|
5
5
|
* `discovery.ts`):
|
|
6
6
|
* - `client.projects.list()` → GET /projects
|
|
7
7
|
* - `client.projects.create({...})` → POST /projects
|
|
8
|
-
* - `client.projects.get(slug)` → GET
|
|
9
|
-
* - `client.projects.update(slug,...)` → PATCH
|
|
8
|
+
* - `client.projects.get(slug)` → GET /projects/<projectSlug>
|
|
9
|
+
* - `client.projects.update(slug,...)` → PATCH /projects/<projectSlug>
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* `orgSlug`
|
|
13
|
-
*
|
|
14
|
-
* operate member-plane and use the project slug.
|
|
11
|
+
* All four operations are org-plane: the client needs an org context
|
|
12
|
+
* (an `orgSlug` or a bearer token that carries one). Input property
|
|
13
|
+
* names are the gateway's body property names.
|
|
15
14
|
*
|
|
16
|
-
*
|
|
15
|
+
* All methods throw KError on non-2xx.
|
|
17
16
|
*/
|
|
18
17
|
|
|
19
18
|
import { request, type HttpClientConfig } from "./http.ts";
|
|
20
|
-
import { orgPath,
|
|
19
|
+
import { orgPath, validateSlug } from "./url-grammar.ts";
|
|
21
20
|
import type { Envelope } from "./envelope.ts";
|
|
22
21
|
|
|
23
22
|
/**
|
|
24
|
-
* Project
|
|
25
|
-
*
|
|
26
|
-
* narrow — those are accessible via `as unknown as` widening.
|
|
23
|
+
* Project record, as the gateway returns it. Field names are passed through unchanged.
|
|
24
|
+
* Lists and creates also carry `capabilities`; lists add `accessLevel`.
|
|
27
25
|
*/
|
|
28
26
|
export type Project = {
|
|
29
27
|
/** `proj_<base62>`. */
|
|
30
28
|
id: string;
|
|
31
29
|
slug: string;
|
|
32
|
-
name: string;
|
|
33
|
-
custodyMode: "managed" | "byok" | "hyok";
|
|
34
|
-
createdAt: string;
|
|
35
|
-
updatedAt: string | null;
|
|
36
30
|
orgId: string;
|
|
31
|
+
displayName: string;
|
|
32
|
+
description: string | null;
|
|
33
|
+
productScope: string;
|
|
34
|
+
/** Region code of the project's data. */
|
|
35
|
+
region: string;
|
|
36
|
+
custodyMode: "PLATFORM_MANAGED" | "SKR" | "BYOK" | "HYOK";
|
|
37
|
+
/** `provisioning` while the project is being set up, then `active`; other values may appear. */
|
|
38
|
+
status: string;
|
|
39
|
+
/** The caller's role on the project, or `null` when the caller is not a member. */
|
|
40
|
+
role: string | null;
|
|
41
|
+
deletedAt: string | null;
|
|
42
|
+
createdAt: string;
|
|
43
|
+
/** Soft-delete retention window in days. */
|
|
44
|
+
softDeleteRetentionDays: number;
|
|
45
|
+
/** Regions the project's data may live in; `[region]` for a single-region project. */
|
|
46
|
+
residencySet: ReadonlyArray<string>;
|
|
47
|
+
/** `"strict"`, or `{ fallbackRegion }` when a catch-all region is set. */
|
|
48
|
+
residencyPolicy: "strict" | { fallbackRegion: string };
|
|
49
|
+
processingContexts: ReadonlyArray<string>;
|
|
50
|
+
/** Present on list and create responses: what the caller may write. */
|
|
51
|
+
capabilities?: { authFlows: { write: boolean }; authProviders: { write: boolean } } | undefined;
|
|
52
|
+
/** Present on list responses: `member` or `locked`. */
|
|
53
|
+
accessLevel?: "member" | "locked" | undefined;
|
|
37
54
|
};
|
|
38
55
|
|
|
39
56
|
/**
|
|
40
57
|
* Project-creation input.
|
|
41
58
|
*/
|
|
42
59
|
export type CreateProjectInput = {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
/** Display name (1 to 128 characters). Required. */
|
|
61
|
+
displayName: string;
|
|
62
|
+
/** URL slug (lowercase); the gateway derives one from `displayName` when omitted. */
|
|
63
|
+
slug?: string | undefined;
|
|
64
|
+
productScope?: "core" | "kibo" | undefined;
|
|
65
|
+
/** Region code of the project's data. */
|
|
66
|
+
region?: string | undefined;
|
|
67
|
+
defaultProjectRegion?: string | undefined;
|
|
68
|
+
custodyMode?: "PLATFORM_MANAGED" | "SKR" | "BYOK" | "HYOK" | undefined;
|
|
69
|
+
processingContexts?: ReadonlyArray<string> | undefined;
|
|
70
|
+
execSeamConfined?: boolean | undefined;
|
|
71
|
+
showroomVertical?: string | undefined;
|
|
72
|
+
persona?: string | undefined;
|
|
73
|
+
showroomLocale?: { code: string; currency: string; moneyScale: number } | undefined;
|
|
74
|
+
/** Makes a retried create return the first result instead of a second project. */
|
|
75
|
+
idempotencyKey?: string | undefined;
|
|
46
76
|
};
|
|
47
77
|
|
|
48
78
|
/**
|
|
49
79
|
* Project-update patch.
|
|
50
80
|
*/
|
|
51
81
|
export type UpdateProjectInput = {
|
|
52
|
-
|
|
82
|
+
slug?: string | undefined;
|
|
83
|
+
displayName?: string | undefined;
|
|
84
|
+
description?: string | null | undefined;
|
|
85
|
+
region?: string | undefined;
|
|
86
|
+
productScope?: string | undefined;
|
|
87
|
+
softDeleteRetentionDays?: number | undefined;
|
|
88
|
+
processingContexts?: ReadonlyArray<string> | undefined;
|
|
53
89
|
};
|
|
54
90
|
|
|
55
91
|
/**
|
|
@@ -73,7 +109,7 @@ export function makeProjectsNamespace(config: HttpClientConfig) {
|
|
|
73
109
|
* Create a new project under the caller's active org.
|
|
74
110
|
*/
|
|
75
111
|
async create(input: CreateProjectInput): Promise<Envelope<Project>> {
|
|
76
|
-
validateSlug(input.slug, "project");
|
|
112
|
+
if (input.slug !== undefined) validateSlug(input.slug, "project");
|
|
77
113
|
return request<Project>(config, {
|
|
78
114
|
method: "POST",
|
|
79
115
|
path: orgPath("projects"),
|
|
@@ -88,7 +124,7 @@ export function makeProjectsNamespace(config: HttpClientConfig) {
|
|
|
88
124
|
validateSlug(slug, "project");
|
|
89
125
|
return request<Project>(config, {
|
|
90
126
|
method: "GET",
|
|
91
|
-
path:
|
|
127
|
+
path: orgPath(`projects/${slug}`),
|
|
92
128
|
});
|
|
93
129
|
},
|
|
94
130
|
|
|
@@ -99,7 +135,7 @@ export function makeProjectsNamespace(config: HttpClientConfig) {
|
|
|
99
135
|
validateSlug(slug, "project");
|
|
100
136
|
return request<Project>(config, {
|
|
101
137
|
method: "PATCH",
|
|
102
|
-
path:
|
|
138
|
+
path: orgPath(`projects/${slug}`),
|
|
103
139
|
body: patch,
|
|
104
140
|
});
|
|
105
141
|
},
|
package/src/realtime.ts
CHANGED
|
@@ -7,33 +7,28 @@
|
|
|
7
7
|
* yielding `RealtimeEvent`s — the caller writes idiomatic
|
|
8
8
|
* `for await (const event of client.realtime.subscribe(topic)) { ... }`.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* choice. The consumer sees one uniform async iterable regardless.
|
|
10
|
+
* Transport: SSE over `GET /@<slug>/realtime/subscribe`. The gateway
|
|
11
|
+
* serves no WebSocket upgrade and no durable replay, so neither is
|
|
12
|
+
* offered; after an `event: resync` frame the client re-reads current
|
|
13
|
+
* state from the data plane and resubscribes.
|
|
15
14
|
*
|
|
16
|
-
* Wire mapping (project-plane
|
|
17
|
-
* - subscribe → GET /@<slug>/realtime/subscribe?topic=<t>&filter=<jsonb>
|
|
18
|
-
* - replay → POST /@<slug>/realtime/replay { topic, fromSequence, untilSequence?, limit? }
|
|
15
|
+
* Wire mapping (project-plane):
|
|
16
|
+
* - subscribe → GET /@<slug>/realtime/subscribe?topic=<t>&filter=<jsonb> (SSE)
|
|
19
17
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* K9O_SUBSCRIPTION_LIMIT_EXCEEDED + K9O_TIER_FEATURE_GATED.
|
|
18
|
+
* `filter` + RLS compose (filter narrows, RLS gates). Surfaces
|
|
19
|
+
* K9O_INVALID_FILTER, K9O_SUBSCRIPTION_LIMIT_EXCEEDED and K9O_TIER_FEATURE_GATED.
|
|
23
20
|
*/
|
|
24
21
|
|
|
25
|
-
import {
|
|
22
|
+
import { requestStream, type HttpClientConfig } from "./http.ts";
|
|
26
23
|
import { projectPath } from "./url-grammar.ts";
|
|
27
|
-
import { KError } from "./kerror.ts";
|
|
28
|
-
import type { Envelope } from "./envelope.ts";
|
|
29
24
|
import { parseSseStream } from "./internal/sse.ts";
|
|
30
25
|
|
|
31
26
|
// ----------------------------------------------------------------------
|
|
32
|
-
// Topic catalogue
|
|
27
|
+
// Topic catalogue
|
|
33
28
|
// ----------------------------------------------------------------------
|
|
34
29
|
|
|
35
30
|
/**
|
|
36
|
-
* The 6 closed subscription topic families. Concrete
|
|
31
|
+
* The 6 closed subscription topic families . Concrete
|
|
37
32
|
* topic strings carry a dynamic segment (collection name, resource id,
|
|
38
33
|
* event family) — use `realtimeTopic.*` builders to construct them.
|
|
39
34
|
*/
|
|
@@ -49,7 +44,7 @@ export type RealtimeTopicFamily =
|
|
|
49
44
|
export type RealtimeTopic = string;
|
|
50
45
|
|
|
51
46
|
/**
|
|
52
|
-
* Typed builders for the 6 topic families. Prefer these
|
|
47
|
+
* Typed builders for the 6 topic families . Prefer these
|
|
53
48
|
* over hand-built strings for compile-time family safety.
|
|
54
49
|
*/
|
|
55
50
|
export const realtimeTopic = {
|
|
@@ -83,16 +78,14 @@ export const realtimeTopic = {
|
|
|
83
78
|
// Event + filter shapes
|
|
84
79
|
// ----------------------------------------------------------------------
|
|
85
80
|
|
|
86
|
-
/** Per-topic filter predicate
|
|
81
|
+
/** Per-topic filter predicate . */
|
|
87
82
|
export type RealtimeFilter = Record<string, unknown>;
|
|
88
83
|
|
|
89
|
-
/** A single realtime event
|
|
84
|
+
/** A single realtime event, as the gateway frames it (wire spelling). */
|
|
90
85
|
export type RealtimeEvent<T = unknown> = {
|
|
91
86
|
topic: RealtimeTopic;
|
|
92
87
|
/** Event type within the topic family (e.g. `row_inserted`, `workflow.run.completed`). */
|
|
93
|
-
|
|
94
|
-
/** Monotonic sequence number — use as `fromSequence` for replay catch-up. */
|
|
95
|
-
sequence: number;
|
|
88
|
+
eventType: string;
|
|
96
89
|
payload: T;
|
|
97
90
|
occurredAt: string;
|
|
98
91
|
};
|
|
@@ -101,35 +94,15 @@ export type SubscribeOptions = {
|
|
|
101
94
|
filter?: RealtimeFilter;
|
|
102
95
|
/** Abort the subscription (closes the underlying transport). */
|
|
103
96
|
signal?: AbortSignal;
|
|
104
|
-
/**
|
|
105
|
-
* Force a transport instead of the WebSocket → SSE → poll cascade.
|
|
106
|
-
* Mainly for tests + constrained runtimes. Omit for production —
|
|
107
|
-
* the SDK picks the richest available transport.
|
|
108
|
-
*/
|
|
109
|
-
transport?: RealtimeTransportKind;
|
|
110
|
-
/** Poll-transport interval in ms (default 2000). Only used when polling. */
|
|
111
|
-
pollIntervalMs?: number;
|
|
112
97
|
};
|
|
113
98
|
|
|
114
|
-
export type RealtimeTransportKind = "websocket" | "sse" | "poll";
|
|
115
|
-
|
|
116
99
|
/**
|
|
117
100
|
* A live subscription. It is itself an async iterable (`for await`) and
|
|
118
|
-
* exposes `close()` for explicit teardown
|
|
101
|
+
* exposes `close()` for explicit teardown.
|
|
119
102
|
*/
|
|
120
103
|
export type RealtimeSubscription<T = unknown> = AsyncIterable<RealtimeEvent<T>> & {
|
|
121
104
|
/** Stop the subscription + release the underlying transport. */
|
|
122
105
|
close(): void;
|
|
123
|
-
/** The transport actually selected (post-fallback). */
|
|
124
|
-
readonly transport: RealtimeTransportKind;
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
export type ReplayInput = {
|
|
128
|
-
topic: RealtimeTopic;
|
|
129
|
-
filter?: RealtimeFilter;
|
|
130
|
-
fromSequence: number;
|
|
131
|
-
untilSequence?: number;
|
|
132
|
-
limit?: number;
|
|
133
106
|
};
|
|
134
107
|
|
|
135
108
|
function requireProjectSlug(config: HttpClientConfig): string {
|
|
@@ -139,90 +112,7 @@ function requireProjectSlug(config: HttpClientConfig): string {
|
|
|
139
112
|
return config.projectSlug;
|
|
140
113
|
}
|
|
141
114
|
|
|
142
|
-
/**
|
|
143
|
-
function selectTransport(forced?: RealtimeTransportKind): RealtimeTransportKind {
|
|
144
|
-
if (forced) return forced;
|
|
145
|
-
if (typeof (globalThis as { WebSocket?: unknown }).WebSocket === "function") return "websocket";
|
|
146
|
-
// SSE rides on fetch streaming, which the http layer already requires.
|
|
147
|
-
return "sse";
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Convert an http(s) baseUrl to a ws(s) URL for the WebSocket transport. */
|
|
151
|
-
function toWebSocketUrl(baseUrl: string, path: string, query: Record<string, string>): string {
|
|
152
|
-
const trimmed = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
|
153
|
-
const wsBase = trimmed.replace(/^http/, "ws");
|
|
154
|
-
const qs = new URLSearchParams(query).toString();
|
|
155
|
-
return `${wsBase}${path}${qs ? `?${qs}` : ""}`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** WebSocket transport — primary. Bridges socket → async queue. */
|
|
159
|
-
async function* websocketTransport<T>(
|
|
160
|
-
config: HttpClientConfig,
|
|
161
|
-
topic: RealtimeTopic,
|
|
162
|
-
options: SubscribeOptions,
|
|
163
|
-
closed: { value: boolean },
|
|
164
|
-
): AsyncGenerator<RealtimeEvent<T>, void, void> {
|
|
165
|
-
const slug = requireProjectSlug(config);
|
|
166
|
-
const query: Record<string, string> = { topic };
|
|
167
|
-
if (options.filter) query.filter = JSON.stringify(options.filter);
|
|
168
|
-
if (config.apiKey) query.access_token = config.apiKey; // WS can't set Authorization header
|
|
169
|
-
const url = toWebSocketUrl(config.baseUrl, projectPath(slug, "realtime/subscribe"), query);
|
|
170
|
-
const WS = (globalThis as { WebSocket: new (url: string) => WebSocket }).WebSocket;
|
|
171
|
-
const socket = new WS(url);
|
|
172
|
-
|
|
173
|
-
const queue: RealtimeEvent<T>[] = [];
|
|
174
|
-
let resolveNext: (() => void) | null = null;
|
|
175
|
-
let socketClosed = false;
|
|
176
|
-
let socketError: Error | null = null;
|
|
177
|
-
|
|
178
|
-
const wake = () => {
|
|
179
|
-
if (resolveNext) {
|
|
180
|
-
resolveNext();
|
|
181
|
-
resolveNext = null;
|
|
182
|
-
}
|
|
183
|
-
};
|
|
184
|
-
socket.onmessage = (ev: MessageEvent) => {
|
|
185
|
-
try {
|
|
186
|
-
queue.push(JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data)) as RealtimeEvent<T>);
|
|
187
|
-
} catch {
|
|
188
|
-
/* ignore malformed frame */
|
|
189
|
-
}
|
|
190
|
-
wake();
|
|
191
|
-
};
|
|
192
|
-
socket.onerror = () => {
|
|
193
|
-
socketError = new Error("realtime websocket transport error");
|
|
194
|
-
wake();
|
|
195
|
-
};
|
|
196
|
-
socket.onclose = () => {
|
|
197
|
-
socketClosed = true;
|
|
198
|
-
wake();
|
|
199
|
-
};
|
|
200
|
-
options.signal?.addEventListener("abort", () => socket.close());
|
|
201
|
-
|
|
202
|
-
try {
|
|
203
|
-
while (!closed.value) {
|
|
204
|
-
if (queue.length > 0) {
|
|
205
|
-
yield queue.shift() as RealtimeEvent<T>;
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (socketClosed) {
|
|
209
|
-
if (socketError) throw socketError;
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
await new Promise<void>((resolve) => {
|
|
213
|
-
resolveNext = resolve;
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
} finally {
|
|
217
|
-
try {
|
|
218
|
-
socket.close();
|
|
219
|
-
} catch {
|
|
220
|
-
/* already closed */
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** SSE transport — fallback. Rides on fetch streaming. */
|
|
115
|
+
/** SSE transport — rides on fetch streaming. `resync` control frames are not events and are skipped. */
|
|
226
116
|
async function* sseTransport<T>(
|
|
227
117
|
config: HttpClientConfig,
|
|
228
118
|
topic: RealtimeTopic,
|
|
@@ -240,73 +130,33 @@ async function* sseTransport<T>(
|
|
|
240
130
|
});
|
|
241
131
|
for await (const event of parseSseStream<RealtimeEvent<T>>(response.body as ReadableStream<Uint8Array>)) {
|
|
242
132
|
if (closed.value || options.signal?.aborted) return;
|
|
133
|
+
if (typeof (event as { eventType?: unknown }).eventType !== "string") continue;
|
|
243
134
|
yield event;
|
|
244
135
|
}
|
|
245
136
|
}
|
|
246
137
|
|
|
247
|
-
/** Poll transport — last-resort fallback (SPLIT, SDK-internal). */
|
|
248
|
-
async function* pollTransport<T>(
|
|
249
|
-
config: HttpClientConfig,
|
|
250
|
-
topic: RealtimeTopic,
|
|
251
|
-
options: SubscribeOptions,
|
|
252
|
-
closed: { value: boolean },
|
|
253
|
-
): AsyncGenerator<RealtimeEvent<T>, void, void> {
|
|
254
|
-
const slug = requireProjectSlug(config);
|
|
255
|
-
const interval = options.pollIntervalMs ?? 2000;
|
|
256
|
-
let cursor = 0;
|
|
257
|
-
while (!closed.value && !options.signal?.aborted) {
|
|
258
|
-
const { data } = await request<ReadonlyArray<RealtimeEvent<T>>>(config, {
|
|
259
|
-
method: "POST",
|
|
260
|
-
path: projectPath(slug, "realtime/replay"),
|
|
261
|
-
body: { topic, filter: options.filter, fromSequence: cursor },
|
|
262
|
-
});
|
|
263
|
-
for (const event of data ?? []) {
|
|
264
|
-
if (closed.value || options.signal?.aborted) return;
|
|
265
|
-
cursor = Math.max(cursor, event.sequence + 1);
|
|
266
|
-
yield event;
|
|
267
|
-
}
|
|
268
|
-
if (closed.value || options.signal?.aborted) return;
|
|
269
|
-
await new Promise<void>((resolve) => setTimeout(resolve, interval));
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
138
|
export function makeRealtimeNamespace(config: HttpClientConfig) {
|
|
274
139
|
return {
|
|
275
140
|
/**
|
|
276
141
|
* Subscribe to a topic. Returns a `RealtimeSubscription` — an async
|
|
277
|
-
* iterable of `RealtimeEvent`s
|
|
278
|
-
* transport (WebSocket → SSE → poll); the caller's
|
|
279
|
-
* `for await` loop is transport-agnostic.
|
|
142
|
+
* iterable of `RealtimeEvent`s over SSE.
|
|
280
143
|
*
|
|
281
144
|
* ```ts
|
|
282
145
|
* const sub = client.realtime.subscribe(
|
|
283
146
|
* realtimeTopic.dashboardRefresh("dsh_abc"),
|
|
284
147
|
* );
|
|
285
148
|
* for await (const event of sub) {
|
|
286
|
-
* if (event.
|
|
149
|
+
* if (event.eventType === "dashboard.widget.data_refreshed") rerender(event.payload);
|
|
287
150
|
* }
|
|
288
151
|
* ```
|
|
289
152
|
*/
|
|
290
153
|
subscribe<T = unknown>(topic: RealtimeTopic, options: SubscribeOptions = {}): RealtimeSubscription<T> {
|
|
291
154
|
requireProjectSlug(config);
|
|
292
|
-
const transport = selectTransport(options.transport);
|
|
293
155
|
const closed = { value: false };
|
|
294
|
-
|
|
295
|
-
const iterate = (): AsyncGenerator<RealtimeEvent<T>, void, void> => {
|
|
296
|
-
switch (transport) {
|
|
297
|
-
case "websocket":
|
|
298
|
-
return websocketTransport<T>(config, topic, options, closed);
|
|
299
|
-
case "poll":
|
|
300
|
-
return pollTransport<T>(config, topic, options, closed);
|
|
301
|
-
case "sse":
|
|
302
|
-
default:
|
|
303
|
-
return sseTransport<T>(config, topic, options, closed);
|
|
304
|
-
}
|
|
305
|
-
};
|
|
156
|
+
const iterate = (): AsyncGenerator<RealtimeEvent<T>, void, void> => sseTransport<T>(config, topic, options, closed);
|
|
306
157
|
|
|
307
158
|
let active: AsyncGenerator<RealtimeEvent<T>, void, void> | null = null;
|
|
308
159
|
return {
|
|
309
|
-
transport,
|
|
310
160
|
[Symbol.asyncIterator]() {
|
|
311
161
|
active = iterate();
|
|
312
162
|
return active;
|
|
@@ -317,34 +167,5 @@ export function makeRealtimeNamespace(config: HttpClientConfig) {
|
|
|
317
167
|
},
|
|
318
168
|
};
|
|
319
169
|
},
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* One-shot historical replay — catch up on missed
|
|
323
|
-
* events from `fromSequence`. Capped at 10k events server-side;
|
|
324
|
-
* paginate via the returned events' `sequence`. Use the highest
|
|
325
|
-
* `sequence` you've seen as the next `fromSequence`.
|
|
326
|
-
*/
|
|
327
|
-
async replay<T = unknown>(input: ReplayInput): Promise<Envelope<ReadonlyArray<RealtimeEvent<T>>>> {
|
|
328
|
-
const slug = requireProjectSlug(config);
|
|
329
|
-
if (!Number.isInteger(input.fromSequence) || input.fromSequence < 0) {
|
|
330
|
-
throw new KError({
|
|
331
|
-
code: "K9O_INVALID_FILTER",
|
|
332
|
-
message: "replay fromSequence must be a non-negative integer",
|
|
333
|
-
context: { fromSequence: input.fromSequence },
|
|
334
|
-
recommendation: "Pass the highest sequence number you have already observed (or 0 to replay from the start).",
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
return request(config, {
|
|
338
|
-
method: "POST",
|
|
339
|
-
path: projectPath(slug, "realtime/replay"),
|
|
340
|
-
body: {
|
|
341
|
-
topic: input.topic,
|
|
342
|
-
filter: input.filter,
|
|
343
|
-
fromSequence: input.fromSequence,
|
|
344
|
-
untilSequence: input.untilSequence,
|
|
345
|
-
limit: input.limit,
|
|
346
|
-
},
|
|
347
|
-
});
|
|
348
|
-
},
|
|
349
170
|
};
|
|
350
171
|
}
|
package/src/region.ts
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Regional host resolution
|
|
3
|
-
*
|
|
2
|
+
* Regional host resolution.
|
|
3
|
+
*
|
|
4
|
+
* A region-free org host `<org>.api.<rest>` (or `<org>.api-staging.<rest>`) redirects GET/HEAD only and REFUSES a
|
|
5
|
+
* request that carries a body, an `Authorization` header or a cookie (`400 K9O_INVALID_REQUEST_SHAPE`,
|
|
6
|
+
* `region_free_host_takes_no_credentials`), so credentials must go to `<org>.api-<rgn>.<rest>`. The client learns the
|
|
7
|
+
* region with ONE unauthenticated `HEAD` to the configured host, takes the `308` `Location`, and caches the regional
|
|
8
|
+
* origin.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here knows an apex. The host comes from the configured baseUrl; `<rest>` is whatever follows the second
|
|
11
|
+
* label, so a public-cloud, internal-apex, air-gapped or localhost deployment all work. Properties:
|
|
12
|
+
* - Only a host shaped `<org>.api.<rest>` / `<org>.api-staging.<rest>` is probed. A regional, single-label, IP,
|
|
13
|
+
* localhost or any other baseUrl is used exactly as given.
|
|
14
|
+
* - The probe carries NO credential and none of the configured default headers (they may hold one).
|
|
15
|
+
* - The `Location` is trusted only if it is `https`, has no userinfo/port, and its host is exactly the SAME org and
|
|
16
|
+
* SAME remainder with a 4-character region code appended to the second label. Anything else is ignored and the
|
|
17
|
+
* configured baseUrl stays, so a hostile redirect can never receive a credential.
|
|
18
|
+
* - Not a redirect, or any failure: the configured baseUrl stays and the server's own refusal, if any, is what the
|
|
19
|
+
* caller sees. A definite answer is cached for the life of the client's config; a network failure is not cached.
|
|
4
20
|
*/
|
|
5
|
-
|
|
21
|
+
|
|
22
|
+
import { parseApiHost } from "./url-grammar.ts";
|
|
23
|
+
|
|
24
|
+
/** The slice of a client config this module needs. */
|
|
25
|
+
export type RegionConfig = { baseUrl: string; fetch?: typeof globalThis.fetch | undefined };
|
|
26
|
+
|
|
27
|
+
const cache = new WeakMap<RegionConfig, Promise<string>>();
|
|
28
|
+
|
|
29
|
+
/** The regional origin a `Location` may name for `host`, or null if it is not exactly that. Pure. */
|
|
30
|
+
export function regionalOriginFromLocation(host: string, location: string, base: string): string | null {
|
|
31
|
+
const from = parseApiHost(host);
|
|
32
|
+
if (from === null || from.orgSlug === null || from.region !== null) return null;
|
|
33
|
+
let u: URL;
|
|
34
|
+
try {
|
|
35
|
+
u = new URL(location, base);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (u.protocol !== "https:" || u.username !== "" || u.password !== "" || u.port !== "") return null;
|
|
40
|
+
const to = parseApiHost(u.hostname);
|
|
41
|
+
if (to === null || to.region === null) return null;
|
|
42
|
+
if (to.orgSlug !== from.orgSlug || to.family !== from.family || to.apex !== from.apex) return null;
|
|
43
|
+
return `https://${u.hostname}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function probe(config: RegionConfig): Promise<{ origin: string; cacheable: boolean }> {
|
|
47
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
48
|
+
const configured = config.baseUrl;
|
|
49
|
+
if (typeof fetchImpl !== "function") return { origin: configured, cacheable: false };
|
|
50
|
+
let host: string;
|
|
51
|
+
try {
|
|
52
|
+
host = new URL(configured).hostname;
|
|
53
|
+
} catch {
|
|
54
|
+
return { origin: configured, cacheable: true };
|
|
55
|
+
}
|
|
56
|
+
const parsedHost = parseApiHost(host);
|
|
57
|
+
if (parsedHost === null || parsedHost.orgSlug === null || parsedHost.region !== null) return { origin: configured, cacheable: true };
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetchImpl(`${configured.replace(/\/+$/, "")}/`, {
|
|
60
|
+
method: "HEAD",
|
|
61
|
+
redirect: "manual",
|
|
62
|
+
headers: { accept: "*/*" },
|
|
63
|
+
} as Parameters<typeof fetchImpl>[1]);
|
|
64
|
+
if (res.status === 308) {
|
|
65
|
+
const loc = res.headers.get("location");
|
|
66
|
+
const origin = loc === null ? null : regionalOriginFromLocation(host, loc, configured);
|
|
67
|
+
if (origin !== null) return { origin, cacheable: true };
|
|
68
|
+
}
|
|
69
|
+
return { origin: configured, cacheable: true };
|
|
70
|
+
} catch {
|
|
71
|
+
return { origin: configured, cacheable: false };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The origin requests for this client go to: the cached regional origin once learned, else the configured baseUrl. */
|
|
76
|
+
export function resolveRegionalOrigin(config: RegionConfig): Promise<string> {
|
|
77
|
+
const hit = cache.get(config);
|
|
78
|
+
if (hit !== undefined) return hit;
|
|
79
|
+
const p = probe(config).then((r) => {
|
|
80
|
+
if (!r.cacheable) cache.delete(config);
|
|
81
|
+
return r.origin;
|
|
82
|
+
});
|
|
83
|
+
cache.set(config, p);
|
|
84
|
+
return p;
|
|
85
|
+
}
|
package/src/storage-buckets.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* `storage-locations.ts` and is unchanged.
|
|
5
5
|
*
|
|
6
6
|
* Bucket names are logical: the platform resolves them to a physical store, so a developer never sees a
|
|
7
|
-
* physical bucket or key. Signed URLs require an explicit `expiresIn` (no default
|
|
7
|
+
* physical bucket or key. Signed URLs require an explicit `expiresIn` (no default). The gateway accepts 1 to 604800 seconds but never
|
|
8
|
+
* signs for longer than 900 seconds, and the answer's `expiresIn` is the lifetime it actually used.
|
|
8
9
|
*
|
|
9
10
|
* What is NOT here yet, by design: a signed PUT (a method- and content-type-bound upload URL). The gateway has
|
|
10
11
|
* no route for it; uploads go through the multipart `upload` below.
|
|
@@ -18,14 +19,15 @@ import type { Envelope } from "./envelope.ts";
|
|
|
18
19
|
/** A bucket. */
|
|
19
20
|
export type StorageBucket = {
|
|
20
21
|
name: string;
|
|
21
|
-
/**
|
|
22
|
+
/** Stored and returned, not enforced: setting it does not yet change who can read objects. */
|
|
22
23
|
public: boolean;
|
|
23
24
|
/** Bytes; `null` = no limit. */
|
|
24
25
|
fileSizeLimit: number | null;
|
|
25
26
|
/** MIME allow-list; empty = any. */
|
|
26
27
|
allowedMimeTypes: string[];
|
|
28
|
+
/** Stored and returned, not enforced: the platform does not keep object versions yet. */
|
|
27
29
|
versioning: boolean;
|
|
28
|
-
/**
|
|
30
|
+
/** Stored and returned, not enforced: nothing deletes objects after this many days yet. `null` = never. */
|
|
29
31
|
deleteAfterDays: number | null;
|
|
30
32
|
createdAt: string;
|
|
31
33
|
updatedAt: string;
|
|
@@ -37,10 +39,13 @@ export type StorageBucket = {
|
|
|
37
39
|
|
|
38
40
|
/** Options for `createBucket`. The bucket name is the first argument (3 to 63 chars, lowercase, digits, hyphens). */
|
|
39
41
|
export type CreateBucketOptions = {
|
|
42
|
+
/** Stored, not enforced yet (see `StorageBucket.public`). */
|
|
40
43
|
public?: boolean;
|
|
41
44
|
fileSizeLimit?: number;
|
|
42
45
|
allowedMimeTypes?: string[] | null;
|
|
46
|
+
/** Stored, not enforced yet. */
|
|
43
47
|
versioning?: boolean;
|
|
48
|
+
/** Stored, not enforced yet. */
|
|
44
49
|
deleteAfterDays?: number;
|
|
45
50
|
/** Link the bucket to one of the project's storage locations (see `client.storage.locations`). */
|
|
46
51
|
byosBindingId?: string;
|
|
@@ -48,10 +53,13 @@ export type CreateBucketOptions = {
|
|
|
48
53
|
|
|
49
54
|
/** Fields you may change on a bucket. `null` clears a limit; `allowedMimeTypes: null` allows any type. */
|
|
50
55
|
export type UpdateBucketOptions = {
|
|
56
|
+
/** Stored, not enforced yet. */
|
|
51
57
|
public?: boolean;
|
|
52
58
|
fileSizeLimit?: number | null;
|
|
53
59
|
allowedMimeTypes?: string[] | null;
|
|
60
|
+
/** Stored, not enforced yet. */
|
|
54
61
|
versioning?: boolean;
|
|
62
|
+
/** Stored, not enforced yet. */
|
|
55
63
|
deleteAfterDays?: number | null;
|
|
56
64
|
/** `null` unlinks the bucket from its storage location. */
|
|
57
65
|
byosBindingId?: string | null;
|
|
@@ -74,12 +82,16 @@ export type UploadOptions = {
|
|
|
74
82
|
};
|
|
75
83
|
|
|
76
84
|
export type SignedUrlOptions = {
|
|
77
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Seconds, required: 1 to 604800. There is deliberately no default. The gateway clamps the lifetime to 900
|
|
87
|
+
* seconds (15 minutes); read `expiresIn` in the answer for the lifetime you got.
|
|
88
|
+
*/
|
|
78
89
|
expiresIn: number;
|
|
79
90
|
/** `true` makes the download an attachment (`Content-Disposition: attachment`). */
|
|
80
91
|
download?: boolean;
|
|
81
92
|
};
|
|
82
93
|
|
|
94
|
+
/** `expiresIn` is the lifetime the gateway used: at most 900 seconds, even when you asked for more. */
|
|
83
95
|
export type SignedUrl = { signedUrl: string; expiresIn: number };
|
|
84
96
|
export type RemovedObject = { bucket: string; path: string; deleted: boolean };
|
|
85
97
|
export type DeletedBucket = { name: string; deleted: true; objectsDeleted: number };
|
|
@@ -172,7 +184,7 @@ export function makeBucketHandle(config: HttpClientConfig, bucket: string) {
|
|
|
172
184
|
},
|
|
173
185
|
|
|
174
186
|
/**
|
|
175
|
-
* A pre-authenticated GET URL for one object. `POST …/objects/sign`. `expiresIn` is required.
|
|
187
|
+
* A pre-authenticated GET URL for one object. `POST …/objects/sign`. `expiresIn` is required; the URL lives at most 900 seconds.
|
|
176
188
|
* (A signed PUT is not offered: the gateway has no route for it yet.)
|
|
177
189
|
*/
|
|
178
190
|
async createSignedUrl(path: string, options: SignedUrlOptions): Promise<Envelope<SignedUrl>> {
|