a2a-exposed 0.5.0 → 0.7.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/README.md +3 -4
- package/lib/a2a.mjs +6 -2
- package/lib/commands.mjs +24 -15
- package/lib/deploy.mjs +20 -17
- package/lib/main.mjs +24 -9
- package/lib/pair.mjs +47 -3
- package/lib/status.mjs +4 -4
- package/lib/tunnel.mjs +11 -11
- package/lib/update.mjs +1 -1
- package/package.json +1 -1
- package/worker/cloudflare.config.ts +51 -7
- package/worker/deploy.env.example +32 -1
- package/worker/migrations/0008_oidc_txns.sql +12 -0
- package/worker/package.json +6 -2
- package/worker/src/approval-oidc.ts +247 -0
- package/worker/src/backup.ts +186 -0
- package/worker/src/directory.ts +120 -0
- package/worker/src/export.ts +156 -0
- package/worker/src/hosted.ts +403 -0
- package/worker/src/index.ts +748 -584
- package/worker/src/mcp.ts +30 -12
- package/worker/src/migrations.ts +22 -0
- package/worker/src/pairing.ts +17 -4
- package/worker/src/sql-raw.d.ts +5 -0
- package/worker/src/storage.ts +100 -0
- package/worker/src/tenancy.ts +450 -0
- package/worker/src/wake-target.ts +202 -0
- package/worker/src/wake.ts +3 -2
- package/worker/test/alarms.test.ts +231 -0
- package/worker/test/approval-oidc.test.ts +331 -0
- package/worker/test/bridge-thread.mjs +40 -0
- package/worker/test/d1.ts +146 -2
- package/worker/test/directory.test.ts +90 -0
- package/worker/test/export.test.ts +544 -0
- package/worker/test/facade.test.ts +1 -1
- package/worker/test/harness-worker.ts +80 -0
- package/worker/test/hosted.test.ts +221 -0
- package/worker/test/migrations.test.ts +5 -5
- package/worker/test/pairing.test.ts +5 -4
- package/worker/test/register-hooks.mjs +4 -0
- package/worker/test/sql-raw-hook.mjs +22 -0
- package/worker/test/storage.test.ts +74 -0
- package/worker/test/tenancy.test.ts +217 -0
- package/worker/test/wake-retry.test.ts +24 -1
- package/worker/test/wake-target.test.ts +187 -0
- package/worker/test/wake.test.ts +3 -0
package/worker/src/mcp.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// metadata. Pure helpers live here; the routes (D1, approval password, owner API) are in index.ts.
|
|
4
4
|
import { esc } from "./pairing.ts";
|
|
5
5
|
import { isPrivateHost } from "./a2a.ts";
|
|
6
|
+
import { derivePeerKey } from "./tenancy.ts";
|
|
6
7
|
|
|
7
8
|
export const SCOPE = "inbox";
|
|
8
9
|
/** Modern (stateless, per-request _meta) versions: no initialize, server/discover, resultType, Mcp-Method / Mcp-Name headers. */
|
|
@@ -169,6 +170,10 @@ export type ConsentModel = {
|
|
|
169
170
|
params: Record<string, string>; // the authorization request, posted back in hidden fields
|
|
170
171
|
passwordSet: boolean;
|
|
171
172
|
notice?: { kind: "ok" | "error" | "info"; text: string };
|
|
173
|
+
/** Set only when OpenID Connect approval is offered. Absent keeps the consent HTML unchanged. */
|
|
174
|
+
oidc?: { label: string };
|
|
175
|
+
/** False hides the password field. Absent means the password field is shown as before. */
|
|
176
|
+
passwordEnabled?: boolean;
|
|
172
177
|
};
|
|
173
178
|
|
|
174
179
|
export function consentBody(m: ConsentModel): string {
|
|
@@ -184,41 +189,54 @@ export function consentBody(m: ConsentModel): string {
|
|
|
184
189
|
const idRow = m.clientIdHost ? `<tr><th>Client identity published at</th><td>${esc(m.clientIdHost)}</td></tr>` : "";
|
|
185
190
|
const table = `<table><tr><th>Client (as it calls itself)</th><td>${esc(m.clientName || "(no name given)")}</td></tr>${idRow}<tr><th>Returns to</th><td>${esc(host)}</td></tr></table>`;
|
|
186
191
|
const scope = `<h2>It will be able to</h2><ul>${can.map((x) => `<li>${esc(x)}</li>`).join("")}</ul><h2>It will not be able to</h2><ul>${cannot.map((x) => `<li>${esc(x)}</li>`).join("")}</ul>`;
|
|
187
|
-
const
|
|
192
|
+
const passwordOn = m.passwordEnabled !== false;
|
|
193
|
+
const form = passwordOn && m.passwordSet
|
|
188
194
|
? `<form method="post" action="/oauth/authorize"><input type="hidden" name="csrf" value="${esc(m.csrf)}">${hidden}
|
|
189
195
|
<label for="pw">Approval password (needed to approve; deny works without it)</label><input id="pw" name="password" type="password" autocomplete="current-password" required maxlength="1024" autofocus>
|
|
190
196
|
<div class="b"><button name="decision" value="approve" type="submit">Approve</button><button name="decision" value="deny" type="submit" class="d" formnovalidate>Deny</button></div></form>`
|
|
191
|
-
:
|
|
192
|
-
|
|
197
|
+
: passwordOn
|
|
198
|
+
? `<p class="n info">No approval password is set yet: ask your agent to run <code>${esc(m.cli)} pair set-password --web</code> and open the one-time link it sends you, then start connecting again.</p>
|
|
199
|
+
<form method="post" action="/oauth/authorize"><input type="hidden" name="csrf" value="${esc(m.csrf)}">${hidden}<div class="b"><button name="decision" value="deny" type="submit" class="d">Deny</button></div></form>`
|
|
200
|
+
: `<form method="post" action="/oauth/authorize"><input type="hidden" name="csrf" value="${esc(m.csrf)}">${hidden}<div class="b"><button name="decision" value="deny" type="submit" class="d">Deny</button></div></form>`;
|
|
201
|
+
const oidcForm = m.oidc ? `<form method="post" action="/oauth/authorize"><input type="hidden" name="csrf" value="${esc(m.csrf)}">${hidden}
|
|
202
|
+
<div class="b"><button name="decision" value="oidc" type="submit">Continue with ${esc(m.oidc.label)}</button></div></form>` : "";
|
|
193
203
|
return `${notice}<p>An MCP client asks to use the inbox of <b>${esc(m.agentName)}</b> as a connector.</p>${table}${loop}${scope}
|
|
194
|
-
<p class="w">Approve only if you are adding this connector right now. The name above is claimed by the client. Revoke access any time: <code>${esc(m.cli)} token list</code>, then <code>${esc(m.cli)} token revoke <label></code>.</p>${form}`;
|
|
204
|
+
<p class="w">Approve only if you are adding this connector right now. The name above is claimed by the client. Revoke access any time: <code>${esc(m.cli)} token list</code>, then <code>${esc(m.cli)} token revoke <label></code>.</p>${form}${oidcForm}`;
|
|
195
205
|
}
|
|
196
206
|
|
|
197
|
-
// ------------------------------------------------------------------ outbound peer token encryption (AES-GCM
|
|
207
|
+
// ------------------------------------------------------------------ outbound peer token encryption (AES-GCM)
|
|
208
|
+
// Self-host: HKDF(OWNER_TOKEN), salt "a2a-exposed", info "outbound-peer-token v1" (existing ciphertext still decrypts).
|
|
209
|
+
// Hosted: TenantContext.peerKey() uses the same HKDF with info bound to the tenant id and IKM TENANT_SECRETS_KEY.
|
|
198
210
|
async function peerKey(ownerToken: string): Promise<CryptoKey> {
|
|
199
|
-
|
|
200
|
-
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("a2a-exposed"), info: new TextEncoder().encode("outbound-peer-token v1") },
|
|
201
|
-
base, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
211
|
+
return derivePeerKey(ownerToken);
|
|
202
212
|
}
|
|
203
213
|
const b64 = (b: Uint8Array) => btoa(String.fromCharCode(...b));
|
|
204
214
|
const unb64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
|
205
215
|
|
|
206
|
-
export async function
|
|
216
|
+
export async function sealPeerTokenWithKey(key: CryptoKey, alias: string, token: string): Promise<string> {
|
|
207
217
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
208
|
-
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(alias) },
|
|
218
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(alias) }, key, new TextEncoder().encode(token)));
|
|
209
219
|
return `v1.${b64(iv)}.${b64(ct)}`;
|
|
210
220
|
}
|
|
211
221
|
|
|
222
|
+
export async function sealPeerToken(ownerToken: string, alias: string, token: string): Promise<string> {
|
|
223
|
+
return sealPeerTokenWithKey(await peerKey(ownerToken), alias, token);
|
|
224
|
+
}
|
|
225
|
+
|
|
212
226
|
/** The token, or null when it can't be decrypted (the owner token was rotated since `peers sync`). */
|
|
213
|
-
export async function
|
|
227
|
+
export async function openPeerTokenWithKey(key: CryptoKey, alias: string, sealed: string): Promise<string | null> {
|
|
214
228
|
const [v, iv, ct] = String(sealed || "").split(".");
|
|
215
229
|
if (v !== "v1" || !iv || !ct) return null;
|
|
216
230
|
try {
|
|
217
|
-
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(iv), additionalData: new TextEncoder().encode(alias) },
|
|
231
|
+
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(iv), additionalData: new TextEncoder().encode(alias) }, key, unb64(ct));
|
|
218
232
|
return new TextDecoder().decode(pt);
|
|
219
233
|
} catch { return null; }
|
|
220
234
|
}
|
|
221
235
|
|
|
236
|
+
export async function openPeerToken(ownerToken: string, alias: string, sealed: string): Promise<string | null> {
|
|
237
|
+
return openPeerTokenWithKey(await peerKey(ownerToken), alias, sealed);
|
|
238
|
+
}
|
|
239
|
+
|
|
222
240
|
// ------------------------------------------------------------------ A2A client (outbound, from the Worker)
|
|
223
241
|
/** JSON-RPC endpoint + version from a peer card (prefers 1.0), as the CLI's pickEndpoint. */
|
|
224
242
|
export function pickEndpoint(base: string, card: any): [string, string] {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Inbox migrations bundled into the Worker. Vite inlines each `?raw` import.
|
|
2
|
+
// A new file in worker/migrations/ must be added here; storage tests check the two lists match.
|
|
3
|
+
import m0001 from "../migrations/0001_init.sql?raw";
|
|
4
|
+
import m0002 from "../migrations/0002_wake_budget.sql?raw";
|
|
5
|
+
import m0003 from "../migrations/0003_device_pairing.sql?raw";
|
|
6
|
+
import m0004 from "../migrations/0004_pairing_replace.sql?raw";
|
|
7
|
+
import m0005 from "../migrations/0005_facade_owners.sql?raw";
|
|
8
|
+
import m0006 from "../migrations/0006_mcp.sql?raw";
|
|
9
|
+
import m0007 from "../migrations/0007_cimd.sql?raw";
|
|
10
|
+
import m0008 from "../migrations/0008_oidc_txns.sql?raw";
|
|
11
|
+
import type { Migration } from "./storage.ts";
|
|
12
|
+
|
|
13
|
+
export const MIGRATIONS: Migration[] = [
|
|
14
|
+
{ name: "0001_init.sql", sql: m0001 },
|
|
15
|
+
{ name: "0002_wake_budget.sql", sql: m0002 },
|
|
16
|
+
{ name: "0003_device_pairing.sql", sql: m0003 },
|
|
17
|
+
{ name: "0004_pairing_replace.sql", sql: m0004 },
|
|
18
|
+
{ name: "0005_facade_owners.sql", sql: m0005 },
|
|
19
|
+
{ name: "0006_mcp.sql", sql: m0006 },
|
|
20
|
+
{ name: "0007_cimd.sql", sql: m0007 },
|
|
21
|
+
{ name: "0008_oidc_txns.sql", sql: m0008 },
|
|
22
|
+
];
|
package/worker/src/pairing.ts
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// in Workers and in Node's test runner. The HTTP handlers live in index.ts.
|
|
3
3
|
import { isPrivateHost } from "./a2a.ts";
|
|
4
4
|
|
|
5
|
+
/** How every hint tells an agent to run the CLI: npx, latest release, no global install (agent sandboxes block global
|
|
6
|
+
* installs as untrusted code). WAKE_CLI_COMMAND overrides it for the owner's own wake hints only. */
|
|
7
|
+
export const DEFAULT_CLI = "npx -y a2a-exposed@latest";
|
|
8
|
+
|
|
5
9
|
export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
6
10
|
export const EXPIRES_S = 600; // device / user code lifetime
|
|
7
11
|
export const INTERVAL_S = 5; // minimum polling interval (slow_down adds 5 s each time)
|
|
@@ -179,6 +183,10 @@ export type PageModel = {
|
|
|
179
183
|
askCode?: boolean; // show the code entry form
|
|
180
184
|
passwordSet: boolean;
|
|
181
185
|
codeValue?: string;
|
|
186
|
+
/** Set only when OpenID Connect approval is offered. Absent keeps the page HTML unchanged. */
|
|
187
|
+
oidc?: { label: string };
|
|
188
|
+
/** False hides the password field. Absent means the password field is shown as before. */
|
|
189
|
+
passwordEnabled?: boolean;
|
|
182
190
|
};
|
|
183
191
|
|
|
184
192
|
export function devicePage(m: PageModel, nonce: string): string {
|
|
@@ -201,13 +209,18 @@ export function devicePage(m: PageModel, nonce: string): string {
|
|
|
201
209
|
<div class="b"><button name="decision" value="deny" type="submit" class="d">Deny</button></div>
|
|
202
210
|
</form>`;
|
|
203
211
|
// Deny needs no password (formnovalidate skips the required field): denying grants nothing
|
|
204
|
-
const
|
|
212
|
+
const passwordOn = m.passwordEnabled !== false;
|
|
213
|
+
const form = r && passwordOn && m.passwordSet ? `<form method="post" action="/device">
|
|
205
214
|
<input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(formatUserCode(r.userCode))}">
|
|
206
215
|
<label for="pw">Approval password (needed to approve; deny works without it)</label><input id="pw" name="password" type="password" autocomplete="current-password" required maxlength="1024" autofocus>
|
|
207
216
|
<div class="b"><button name="decision" value="approve" type="submit">Approve</button><button name="decision" value="deny" type="submit" class="d" formnovalidate>Deny</button></div>
|
|
208
|
-
</form>` : r && !m.passwordSet ? noPassword + denyOnly : "";
|
|
217
|
+
</form>` : r && passwordOn && !m.passwordSet ? noPassword + denyOnly : r ? denyOnly : "";
|
|
218
|
+
const oidcForm = m.oidc && r ? `<form method="post" action="/device">
|
|
219
|
+
<input type="hidden" name="csrf" value="${esc(m.csrf)}"><input type="hidden" name="user_code" value="${esc(formatUserCode(r.userCode))}">
|
|
220
|
+
<div class="b"><button name="decision" value="oidc" type="submit">Approve with ${esc(m.oidc.label)}</button></div>
|
|
221
|
+
</form>` : "";
|
|
209
222
|
const ask = m.askCode ? `<form method="get" action="/device"><label for="uc">Code</label><input id="uc" name="user_code" value="${esc(m.codeValue || "")}" placeholder="WDJB-4827" autocomplete="off" autocapitalize="characters" required maxlength="16" autofocus><div class="b"><button type="submit">Continue</button></div></form>` : "";
|
|
210
|
-
return pageShell(`Connect an agent to ${m.agentName}`, nonce, `${notice}${details}${form}${ask}`);
|
|
223
|
+
return pageShell(`Connect an agent to ${m.agentName}`, nonce, `${notice}${details}${form}${oidcForm}${ask}`);
|
|
211
224
|
}
|
|
212
225
|
|
|
213
226
|
const STYLE = `body{font:16px/1.5 system-ui,sans-serif;max-width:34rem;margin:2rem auto;padding:0 1rem;color:#111;background:#fafafa}
|
|
@@ -243,7 +256,7 @@ export function landingPage(m: LandingModel, nonce: string): string {
|
|
|
243
256
|
? `<h2>Skills</h2><ul>${m.skills.slice(0, 20).map((k) => `<li><b>${esc(k.name)}</b>${k.description ? ` – ${esc(k.description)}` : ""}</li>`).join("")}</ul>${m.skills.length > 20 ? `<p class="w">…and ${m.skills.length - 20} more in the agent card.</p>` : ""}`
|
|
244
257
|
: "";
|
|
245
258
|
const connect = m.pairing
|
|
246
|
-
? `<h2>Connect your agent</h2><p>Agents get a bearer token through the OAuth device flow: your agent asks, and this agent's owner approves the request on the <a href="/device">pairing page</a>. No secrets go through chat. With the a2a-exposed CLI, your agent runs:</p><p><code
|
|
259
|
+
? `<h2>Connect your agent</h2><p>Agents get a bearer token through the OAuth device flow: your agent asks, and this agent's owner approves the request on the <a href="/device">pairing page</a>. No secrets go through chat. With the a2a-exposed CLI, your agent runs:</p><p><code>${DEFAULT_CLI} connect ${esc(m.base)}</code></p>`
|
|
247
260
|
: `<h2>Connect your agent</h2><p>Pairing is turned off here: ask this agent's operator for a bearer token.</p>`;
|
|
248
261
|
return pageShell(m.name, nonce, `${desc}
|
|
249
262
|
<p class="n info">This is an <a href="https://a2a-protocol.org/">A2A (Agent2Agent)</a> endpoint, meant for agents rather than people.${m.proxy ? "" : " Messages land in its owner's inbox, which wakes the agent."}</p>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// D1-shaped database over a Durable Object's SQLite (`ctx.storage.sql`).
|
|
2
|
+
// Handlers keep calling prepare().bind().first()/all()/run() and batch(). Self-host keeps the real D1 binding;
|
|
3
|
+
// a hosted tenant uses this adapter on its own object. `migrateDo` matches `cf d1 migrations apply`: the same
|
|
4
|
+
// `d1_migrations` table, unrecorded files in numeric order, one transaction per file.
|
|
5
|
+
|
|
6
|
+
/** The slice of the Workers `SqlStorage` API this adapter uses. Tests can implement it over node:sqlite. */
|
|
7
|
+
export interface SqlStorageLike {
|
|
8
|
+
exec(query: string, ...bindings: unknown[]): SqlCursor;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SqlCursor {
|
|
12
|
+
toArray(): Record<string, unknown>[];
|
|
13
|
+
raw(): Iterable<unknown[]>;
|
|
14
|
+
rowsRead?: number;
|
|
15
|
+
rowsWritten?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type TxRunner = <T>(fn: () => T) => T;
|
|
19
|
+
export type Migration = { name: string; sql: string };
|
|
20
|
+
|
|
21
|
+
const WRITE = /^\s*(?:insert|update|delete|replace)\b/i;
|
|
22
|
+
|
|
23
|
+
/** D1 rejects undefined bindings and stores booleans as 1/0. Mirror both so the backends agree. */
|
|
24
|
+
function norm(v: unknown, i: number): unknown {
|
|
25
|
+
if (v === undefined) throw new Error(`D1_TYPE_ERROR: Type 'undefined' not supported for value at index ${i}`);
|
|
26
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DoSql {
|
|
31
|
+
prepare(query: string): DoStmt;
|
|
32
|
+
batch(statements: DoStmt[]): Promise<{ results?: unknown[]; meta: { changes?: number; last_row_id?: number } }[]>;
|
|
33
|
+
exec(query: string): Promise<{ count: number; duration: number }>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DoStmt {
|
|
37
|
+
__sql: string;
|
|
38
|
+
__args: unknown[];
|
|
39
|
+
bind(...values: unknown[]): DoStmt;
|
|
40
|
+
first<T = unknown>(colName?: string): Promise<T | null>;
|
|
41
|
+
all<T = unknown>(): Promise<{ results?: T[]; meta: { changes?: number; last_row_id?: number } }>;
|
|
42
|
+
run(): Promise<{ results?: unknown[]; meta: { changes?: number; last_row_id?: number } }>;
|
|
43
|
+
raw<T = unknown>(options?: { columnNames?: boolean }): Promise<T[]>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A D1-shaped database over `SqlStorage`. `batch` is one transaction; DO SQL forbids BEGIN/COMMIT, so the caller passes `transactionSync`. */
|
|
47
|
+
export function doSqlD1(sql: SqlStorageLike, tx: TxRunner): DoSql {
|
|
48
|
+
const changesOf = (q: string) => {
|
|
49
|
+
let changes = 0, last_row_id = 0;
|
|
50
|
+
if (WRITE.test(q)) {
|
|
51
|
+
// `rowsWritten` also counts index writes. Ask SQLite for the D1-equivalent `meta.changes`.
|
|
52
|
+
const m = sql.exec("SELECT changes() AS c, last_insert_rowid() AS r").toArray()[0] as { c?: unknown; r?: unknown } | undefined;
|
|
53
|
+
changes = Number(m?.c ?? 0);
|
|
54
|
+
last_row_id = Number(m?.r ?? 0);
|
|
55
|
+
}
|
|
56
|
+
return { changes, last_row_id };
|
|
57
|
+
};
|
|
58
|
+
const result = (q: string, args: unknown[]) => {
|
|
59
|
+
const rows = sql.exec(q, ...args.map(norm)).toArray();
|
|
60
|
+
return { success: true as const, results: rows, meta: changesOf(q) };
|
|
61
|
+
};
|
|
62
|
+
const stmt = (q: string, args: unknown[] = []): DoStmt => ({
|
|
63
|
+
__sql: q,
|
|
64
|
+
__args: args,
|
|
65
|
+
bind: (...a: unknown[]) => stmt(q, a),
|
|
66
|
+
first: async (col?: string) => {
|
|
67
|
+
const r = result(q, args).results[0];
|
|
68
|
+
if (!r) return null;
|
|
69
|
+
if (col === undefined) return r as never;
|
|
70
|
+
if (!(col in r)) throw new Error(`D1_COLUMN_NOTFOUND: Column not found (${col})`);
|
|
71
|
+
return r[col] as never;
|
|
72
|
+
},
|
|
73
|
+
// `T` is the caller's row shape. The cursor only knows records, so the cast is the D1 generic.
|
|
74
|
+
all: async <T>() => result(q, args) as { results: T[]; meta: { changes: number; last_row_id: number } },
|
|
75
|
+
run: async () => result(q, args),
|
|
76
|
+
raw: async <T>() => [...sql.exec(q, ...args.map(norm)).raw()] as T[],
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
prepare: (q: string) => stmt(q),
|
|
80
|
+
batch: async (stmts) => tx(() => stmts.map((s) => result(s.__sql, s.__args))),
|
|
81
|
+
exec: async (q: string) => { sql.exec(q); return { count: 1, duration: 0 }; },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const num = (n: string) => parseInt(n.split("_")[0]!, 10);
|
|
86
|
+
const byNumber = (a: Migration, b: Migration) => num(a.name) - num(b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Same bookkeeping as `cf d1 migrations apply`: create `d1_migrations` if needed, apply every unrecorded file in
|
|
90
|
+
* numeric order, each in its own transaction. A lower number added later still runs when a higher one is already
|
|
91
|
+
* recorded. Returns the names applied (empty once the object is up to date).
|
|
92
|
+
* Call this only after the tenant row exists. An unconfigured object must not create tables.
|
|
93
|
+
*/
|
|
94
|
+
export function migrateDo(sql: SqlStorageLike, tx: TxRunner, migrations: Migration[]): string[] {
|
|
95
|
+
sql.exec("CREATE TABLE IF NOT EXISTS d1_migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL)");
|
|
96
|
+
const done = new Set(sql.exec("SELECT name FROM d1_migrations").toArray().map((r) => String(r.name)));
|
|
97
|
+
const todo = [...migrations].sort(byNumber).filter((m) => !done.has(m.name));
|
|
98
|
+
for (const m of todo) tx(() => { sql.exec(m.sql); sql.exec("INSERT INTO d1_migrations (name) VALUES (?)", m.name); });
|
|
99
|
+
return todo.map((m) => m.name);
|
|
100
|
+
}
|