@kedge/cli 0.10.1 → 0.11.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/kedge-manifest.json +52 -42
- package/package.json +7 -7
- package/src/cf.ts +216 -50
- package/src/cli-session.ts +43 -4
- package/src/commands.ts +26 -9
- package/src/deploy.ts +48 -13
- package/src/doctor.ts +34 -9
- package/src/main.ts +9 -4
- package/src/ops.ts +26 -7
- package/src/preview.ts +5 -0
- package/src/store-api.ts +8 -1
- package/src/store.ts +27 -5
- package/src/target.ts +39 -11
package/src/deploy.ts
CHANGED
|
@@ -37,9 +37,16 @@ import {
|
|
|
37
37
|
sha256,
|
|
38
38
|
storeNames,
|
|
39
39
|
type WranglerJson,
|
|
40
|
+
withDatabaseId,
|
|
40
41
|
} from "./store";
|
|
41
42
|
|
|
42
43
|
export const SCOPE = "production";
|
|
44
|
+
/**
|
|
45
|
+
* The settings a release leaves: no Logpush, no invocation logs, query strings
|
|
46
|
+
* redacted, because a logged or thrown request is recorded with its URL, which
|
|
47
|
+
* erasure can't reach (docs/10; p0.11 row 18).
|
|
48
|
+
*/
|
|
49
|
+
const RELEASE_SETTINGS: WorkerSettings = { logpush: false, invocationLogs: false, redactQueryString: true };
|
|
43
50
|
/** Core's secrets, generated only at a first deploy and only where missing (§6.1). */
|
|
44
51
|
export const CORE_SECRETS = [
|
|
45
52
|
"CART_TOKEN_SECRET",
|
|
@@ -714,6 +721,25 @@ export class Deployer {
|
|
|
714
721
|
else blocking.push(i);
|
|
715
722
|
continue;
|
|
716
723
|
}
|
|
724
|
+
if (i.kind === "triggers") {
|
|
725
|
+
// A schedule PUT or a settings PATCH sets whole values, so a read-back showing them settles it,
|
|
726
|
+
// and a lost answer to one that landed doesn't block --resume (salve's codex r7). A mismatch
|
|
727
|
+
// stays unknown: replaying could race a late landing of the original.
|
|
728
|
+
const d = JSON.parse(i.detail) as { crons?: string[] } & Partial<WorkerSettings>;
|
|
729
|
+
const shows = d.crons
|
|
730
|
+
? JSON.stringify(await this.d.cf.schedules(this.names.worker)) === JSON.stringify(d.crons)
|
|
731
|
+
: await this.d.cf
|
|
732
|
+
.settings(this.names.worker)
|
|
733
|
+
.then((s) =>
|
|
734
|
+
(["logpush", "invocationLogs", "redactQueryString"] as const).every(
|
|
735
|
+
(k) => d[k] === undefined || s[k] === d[k],
|
|
736
|
+
),
|
|
737
|
+
);
|
|
738
|
+
if (shows) {
|
|
739
|
+
await this.ops.closeIntent(i.id, "done", "a read-back shows what it set");
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
717
743
|
if (i.kind === "activate") {
|
|
718
744
|
const deps = await this.d.cf.deployments(this.names.worker);
|
|
719
745
|
const landed = deps.find((d) => d.message?.includes(i.tag));
|
|
@@ -918,8 +944,9 @@ export class Deployer {
|
|
|
918
944
|
if (!existsSync(file)) return;
|
|
919
945
|
const text = readFileSync(file, "utf8");
|
|
920
946
|
if (this.names.databaseId === PLACEHOLDER_ID) {
|
|
921
|
-
|
|
922
|
-
|
|
947
|
+
const written = withDatabaseId(text, id);
|
|
948
|
+
if (written !== text) {
|
|
949
|
+
writeFileSync(file, written);
|
|
923
950
|
this.log(`wrangler.jsonc now names database ${id}: commit it`);
|
|
924
951
|
}
|
|
925
952
|
} else if (this.names.databaseId !== id)
|
|
@@ -1015,10 +1042,9 @@ export class Deployer {
|
|
|
1015
1042
|
const cf = this.d.cf;
|
|
1016
1043
|
const applied = new Set(await appliedNames(cf, p.databaseId as string));
|
|
1017
1044
|
const pending = (p.pending ?? []).filter((f) => !applied.has(f));
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
}
|
|
1045
|
+
await this.ops.recordApplied(
|
|
1046
|
+
this.d.lock.entries.filter((e) => applied.has(e.file)).map((e) => [e.file, e.sha256] as const),
|
|
1047
|
+
);
|
|
1022
1048
|
if (pending.length === 0) return;
|
|
1023
1049
|
await this.step("migrate");
|
|
1024
1050
|
const dir = this.dir(this.opId);
|
|
@@ -1047,7 +1073,9 @@ export class Deployer {
|
|
|
1047
1073
|
);
|
|
1048
1074
|
} finally {
|
|
1049
1075
|
const now = new Set(await appliedNames(cf, p.databaseId as string));
|
|
1050
|
-
|
|
1076
|
+
await this.ops.recordApplied(
|
|
1077
|
+
pending.filter((f) => now.has(f)).map((f) => [f, this.plan.files[f] as string] as const),
|
|
1078
|
+
);
|
|
1051
1079
|
}
|
|
1052
1080
|
}
|
|
1053
1081
|
|
|
@@ -1116,7 +1144,7 @@ export class Deployer {
|
|
|
1116
1144
|
}
|
|
1117
1145
|
if (!classChange) await this.activate("activate", p.appVersion as string);
|
|
1118
1146
|
await this.markReleased();
|
|
1119
|
-
await this.triggers(this.plan.config.triggers?.crons ?? [],
|
|
1147
|
+
await this.triggers(this.plan.config.triggers?.crons ?? [], RELEASE_SETTINGS);
|
|
1120
1148
|
}
|
|
1121
1149
|
|
|
1122
1150
|
/** Crons and settings, each a single-attempt call read back before it counts (§3.1). */
|
|
@@ -1132,13 +1160,17 @@ export class Deployer {
|
|
|
1132
1160
|
throw new DeployRefused("the cron schedules didn't read back as set");
|
|
1133
1161
|
}
|
|
1134
1162
|
const s = await cf.settings(this.names.worker);
|
|
1135
|
-
if (
|
|
1163
|
+
if (
|
|
1164
|
+
s.logpush !== settings.logpush ||
|
|
1165
|
+
s.invocationLogs !== settings.invocationLogs ||
|
|
1166
|
+
s.redactQueryString !== settings.redactQueryString
|
|
1167
|
+
) {
|
|
1136
1168
|
await this.step("settings");
|
|
1137
1169
|
await this.intent("settings", "triggers", this.tag("settings"), settings, () =>
|
|
1138
1170
|
cf.patchSettings(this.names.worker, settings),
|
|
1139
1171
|
);
|
|
1140
1172
|
const back = await cf.settings(this.names.worker);
|
|
1141
|
-
if (back.invocationLogs !== settings.invocationLogs)
|
|
1173
|
+
if (back.invocationLogs !== settings.invocationLogs || back.redactQueryString !== settings.redactQueryString)
|
|
1142
1174
|
throw new DeployRefused("the Worker's settings didn't read back as set");
|
|
1143
1175
|
}
|
|
1144
1176
|
}
|
|
@@ -1341,7 +1373,9 @@ export class Deployer {
|
|
|
1341
1373
|
);
|
|
1342
1374
|
await this.reconcile();
|
|
1343
1375
|
await this.activate("abort", before.versionId);
|
|
1344
|
-
|
|
1376
|
+
// Redaction stays on through a rollback: a "before" read under 0.10 never
|
|
1377
|
+
// recorded it, and turning it off would put query strings back in the logs.
|
|
1378
|
+
await this.triggers(before.crons ?? [], { ...(before.settings ?? RELEASE_SETTINGS), redactQueryString: true });
|
|
1345
1379
|
await ops.completeDeployment(p.deploymentId as string, {
|
|
1346
1380
|
...before,
|
|
1347
1381
|
commit: servingCommit((await ops.deployments()).filter((r) => r.id !== p.deploymentId)),
|
|
@@ -1612,8 +1646,9 @@ export class Deployer {
|
|
|
1612
1646
|
const qid = await cf.findQueue(this.names.queue);
|
|
1613
1647
|
if (qid) await ops.recordResource("queue", this.names.queue, qid, "ours");
|
|
1614
1648
|
await ops.recordResource("worker", this.names.worker, null, "ours");
|
|
1615
|
-
|
|
1616
|
-
|
|
1649
|
+
await ops.recordApplied(
|
|
1650
|
+
applied.map((f) => [f, (this.d.lock.entries.find((e) => e.file === f) as LockEntry).sha256] as const),
|
|
1651
|
+
);
|
|
1617
1652
|
const half: Half = {
|
|
1618
1653
|
lockSha: sha256(
|
|
1619
1654
|
JSON.stringify(this.d.lock.entries.filter((e) => applied.includes(e.file)).map((e) => [e.file, e.sha256])),
|
package/src/doctor.ts
CHANGED
|
@@ -50,7 +50,8 @@ type WranglerConfig = {
|
|
|
50
50
|
renamed_classes?: { from: string; to: string }[];
|
|
51
51
|
}[];
|
|
52
52
|
queues?: { producers?: { binding?: string }[] };
|
|
53
|
-
observability?: { enabled?: boolean; logs?: { invocation_logs?: boolean } };
|
|
53
|
+
observability?: { enabled?: boolean; redact_query_string?: boolean; logs?: { invocation_logs?: boolean } };
|
|
54
|
+
workers_dev?: boolean;
|
|
54
55
|
assets?: { directory?: string; binding?: string; not_found_handling?: string; run_worker_first?: unknown };
|
|
55
56
|
env?: Record<string, { observability?: WranglerConfig["observability"] }>;
|
|
56
57
|
};
|
|
@@ -382,8 +383,11 @@ export async function doctor(input: DoctorInput): Promise<CheckResult[]> {
|
|
|
382
383
|
problems.length ? fail("wrangler", "wrangler.jsonc isn't what a Kedge Worker needs", problems) : pass("wrangler"),
|
|
383
384
|
);
|
|
384
385
|
|
|
385
|
-
// invocation-logs: Cloudflare
|
|
386
|
-
|
|
386
|
+
// invocation-logs: Cloudflare records a request with its URL, which erasure can't reach: every
|
|
387
|
+
// request with invocation logs on, and one that logs or throws with them off unless its query
|
|
388
|
+
// string is redacted (p0.11 row 18).
|
|
389
|
+
const off = (o: WranglerConfig["observability"]) =>
|
|
390
|
+
o?.logs?.invocation_logs === false && o.redact_query_string === true;
|
|
387
391
|
const where = [
|
|
388
392
|
...(off(c.observability) ? [] : ["the top level"]),
|
|
389
393
|
...Object.entries(c.env ?? {})
|
|
@@ -394,7 +398,7 @@ export async function doctor(input: DoctorInput): Promise<CheckResult[]> {
|
|
|
394
398
|
where.length
|
|
395
399
|
? fail(
|
|
396
400
|
"invocation-logs",
|
|
397
|
-
`set "observability": { "logs": { "invocation_logs": false } } at ${where.join(", ")}:
|
|
401
|
+
`set "observability": { "redact_query_string": true, "logs": { "invocation_logs": false } } at ${where.join(", ")}: Cloudflare records a request with its URL, which can carry a customer's data that erasure can't reach`,
|
|
398
402
|
)
|
|
399
403
|
: pass("invocation-logs"),
|
|
400
404
|
);
|
|
@@ -461,13 +465,21 @@ export async function credentialsChecks(input: {
|
|
|
461
465
|
cf: import("./cf").Cloudflare;
|
|
462
466
|
source: string;
|
|
463
467
|
base: string;
|
|
468
|
+
/** Whether the store is served at workers.dev; when it isn't, a missing subdomain is no failure (codex code r3). */
|
|
469
|
+
workersDev?: boolean;
|
|
464
470
|
}): Promise<CheckResult[]> {
|
|
465
471
|
const { NeedsPerson } = await import("./cf");
|
|
466
472
|
const where = `from ${input.source}, sent to ${input.base}, account ${input.cf.account}`;
|
|
467
473
|
const read = await input.cf.readable();
|
|
468
|
-
const refused = Object.entries(read).filter(([, status]) => status !== null);
|
|
474
|
+
const refused = Object.entries(read).filter(([, status]) => status !== null && status !== "not served");
|
|
475
|
+
// A list the platform doesn't serve (salve hosting's Workers) is reported, never a failed credential (p0.11 row 5).
|
|
476
|
+
const notServed = Object.entries(read)
|
|
477
|
+
.filter(([, s]) => s === "not served")
|
|
478
|
+
.map(([kind]) => kind);
|
|
479
|
+
const lists = (["Workers", "D1", "Queues"] as const).filter((k) => !notServed.includes(k));
|
|
480
|
+
const unserved = notServed.length ? `; ${notServed.join(" and ")} listing not available here` : "";
|
|
469
481
|
const out: CheckResult[] = [];
|
|
470
|
-
if (refused.length ===
|
|
482
|
+
if (refused.length && refused.length === lists.length && refused.every(([, s]) => s === 401 || s === 403))
|
|
471
483
|
out.push(fail("credentials", `the token doesn't answer for this account (${where})`));
|
|
472
484
|
else if (refused.length)
|
|
473
485
|
out.push(
|
|
@@ -481,7 +493,7 @@ export async function credentialsChecks(input: {
|
|
|
481
493
|
out.push(
|
|
482
494
|
pass(
|
|
483
495
|
"credentials",
|
|
484
|
-
`${where}; lists
|
|
496
|
+
`${where}; lists ${lists.join(", ").replace(/, ([^,]*)$/, " and $1")}${unserved}. Writes are unverified until the first deploy: Workers Scripts Edit, D1 Edit, Queues Edit`,
|
|
485
497
|
),
|
|
486
498
|
);
|
|
487
499
|
try {
|
|
@@ -489,7 +501,9 @@ export async function credentialsChecks(input: {
|
|
|
489
501
|
} catch (e) {
|
|
490
502
|
// Any answer is a report: a token that can't read the subdomain fails this check, never the run (codex code r7).
|
|
491
503
|
// An account without one is the owner's to choose: a handoff, not a technical failure (codex code r9).
|
|
492
|
-
if (e instanceof NeedsPerson
|
|
504
|
+
if (e instanceof NeedsPerson && input.workersDev === false)
|
|
505
|
+
out.push(skip("workers.dev", 'none, and none needed: wrangler.jsonc says "workers_dev": false'));
|
|
506
|
+
else if (e instanceof NeedsPerson) out.push({ ...fail("workers.dev", e.handoff), handoff: e.handoff });
|
|
493
507
|
else out.push(fail("workers.dev", (e as Error).message));
|
|
494
508
|
}
|
|
495
509
|
return out;
|
|
@@ -549,7 +563,9 @@ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResul
|
|
|
549
563
|
if (last.crons && JSON.stringify(crons) !== JSON.stringify(last.crons))
|
|
550
564
|
problems.push(`crons are ${JSON.stringify(crons)}, recorded ${JSON.stringify(last.crons)}`);
|
|
551
565
|
const settings = await input.cf.settings(names.worker);
|
|
552
|
-
|
|
566
|
+
// Only what was recorded: a 0.10 deployment recorded no redaction, and isn't drift for lacking it.
|
|
567
|
+
const recorded = last.settings;
|
|
568
|
+
if (recorded && (Object.keys(recorded) as (keyof typeof recorded)[]).some((k) => settings[k] !== recorded[k]))
|
|
553
569
|
problems.push(`settings are ${JSON.stringify(settings)}, recorded ${JSON.stringify(last.settings)}`);
|
|
554
570
|
const held = await ops.current(SCOPE);
|
|
555
571
|
out.push(
|
|
@@ -560,6 +576,15 @@ export async function doctorRemote(input: RemoteDoctorInput): Promise<CheckResul
|
|
|
560
576
|
held ? `operation ${held.op_id} (${held.kind}) holds the store at "${held.step}"` : undefined,
|
|
561
577
|
),
|
|
562
578
|
);
|
|
579
|
+
// workers-dev: Wrangler enables workers.dev after a deploy lands, and a platform serving the store elsewhere
|
|
580
|
+
// refuses that (salve hosting, 400/10400), so every `wrangler deploy` would exit non-zero after landing.
|
|
581
|
+
if (readWrangler(input.cwd)?.config.workers_dev !== false && !(await input.cf.workersDevUrl(names.worker)))
|
|
582
|
+
out.push(
|
|
583
|
+
fail(
|
|
584
|
+
"workers-dev",
|
|
585
|
+
`wrangler.jsonc has "workers_dev": true, but ${names.worker} isn't served at workers.dev: on a host that serves it at its own address (salve hosting), set "workers_dev": false and commit it`,
|
|
586
|
+
),
|
|
587
|
+
);
|
|
563
588
|
// do-classes: rollback across a class change is blocked (a warning).
|
|
564
589
|
const planned = (readWrangler(input.cwd)?.config.migrations ?? []) as unknown[];
|
|
565
590
|
if (JSON.stringify(planned) !== JSON.stringify(last.doMigrations ?? []))
|
package/src/main.ts
CHANGED
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
} from "./commands";
|
|
38
38
|
import { ComposeError, compose, type Lock } from "./compose";
|
|
39
39
|
import { type Executor, wranglerExecute } from "./d1-batch";
|
|
40
|
-
import { credentialsChecks, doctor, doctorRemote, formatReport } from "./doctor";
|
|
40
|
+
import { credentialsChecks, doctor, doctorRemote, formatReport, readWrangler } from "./doctor";
|
|
41
41
|
import { invariantsMarkdown, invariantsOf } from "./invariants";
|
|
42
42
|
import { installedPackages } from "./manifest";
|
|
43
43
|
import { emit, withJsonOutput } from "./output";
|
|
@@ -128,8 +128,8 @@ async function inviteOrigin(
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
try {
|
|
131
|
-
|
|
132
|
-
return
|
|
131
|
+
// Only where it's served: elsewhere (salve hosting) the link needs --url, the address its host gives.
|
|
132
|
+
return await (cf ?? (await cloudflareFor(env, cwd))).workersDevUrl(storeNames(cwd).names.worker);
|
|
133
133
|
} catch {
|
|
134
134
|
return null;
|
|
135
135
|
}
|
|
@@ -368,7 +368,12 @@ async function run(argv: string[], cwd: string, deps: RunDeps & { json: boolean
|
|
|
368
368
|
if (auth) {
|
|
369
369
|
const cf = restCloudflare(d.env, fetch, wranglerBin(), auth);
|
|
370
370
|
const lock = JSON.parse(readFileSync(join(cwd, "kedge.lock.json"), "utf8")) as Lock;
|
|
371
|
-
const creds = await credentialsChecks({
|
|
371
|
+
const creds = await credentialsChecks({
|
|
372
|
+
cf,
|
|
373
|
+
source: auth.source,
|
|
374
|
+
base: cloudflareBase(d.env),
|
|
375
|
+
workersDev: readWrangler(cwd)?.config.workers_dev !== false,
|
|
376
|
+
});
|
|
372
377
|
results.push(...creds);
|
|
373
378
|
// Credentials that can't read the account would make every later check throw, not report (codex code r7).
|
|
374
379
|
if (creds.some((c) => c.status === "fail"))
|
package/src/ops.ts
CHANGED
|
@@ -53,6 +53,15 @@ export const OPS_SCHEMA = [
|
|
|
53
53
|
classes TEXT NOT NULL DEFAULT '[]', destroyed_at INTEGER NOT NULL)`,
|
|
54
54
|
];
|
|
55
55
|
|
|
56
|
+
/** Every table and index OPS_SCHEMA makes, by name. */
|
|
57
|
+
const OPS_OBJECTS = OPS_SCHEMA.map((s) => (/IF NOT EXISTS (\w+)/.exec(s) as RegExpExecArray)[1] as string);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Rows per INSERT into `applied`: three bound parameters each, under D1's 100
|
|
61
|
+
* per query (https://developers.cloudflare.com/d1/platform/limits/).
|
|
62
|
+
*/
|
|
63
|
+
const APPLIED_ROWS = 33;
|
|
64
|
+
|
|
56
65
|
export class OperationHeld extends Error {
|
|
57
66
|
constructor(readonly held: OperationRow) {
|
|
58
67
|
super(
|
|
@@ -128,7 +137,13 @@ export class OpsStore {
|
|
|
128
137
|
) {}
|
|
129
138
|
|
|
130
139
|
async init(store: string): Promise<void> {
|
|
131
|
-
|
|
140
|
+
// Every object already there: the statements would change nothing, and a platform's rate limit counts
|
|
141
|
+
// each one (salve: 120 a minute per app, p0.11 row 16). The schema only ever gains objects, never columns.
|
|
142
|
+
const present = await this.q(
|
|
143
|
+
`SELECT name FROM sqlite_master WHERE name IN (${OPS_OBJECTS.map(() => "?").join(", ")})`,
|
|
144
|
+
OPS_OBJECTS,
|
|
145
|
+
);
|
|
146
|
+
if (present.length !== OPS_OBJECTS.length) for (const s of OPS_SCHEMA) await this.q(s);
|
|
132
147
|
await this.q("INSERT INTO kedge_ops_meta (id, store, created_at) VALUES (1, ?, ?) ON CONFLICT (id) DO NOTHING", [
|
|
133
148
|
store,
|
|
134
149
|
this.now(),
|
|
@@ -344,12 +359,16 @@ export class OpsStore {
|
|
|
344
359
|
return new Map((await this.q("SELECT file, sha256 FROM applied")).map((r) => [String(r.file), String(r.sha256)]));
|
|
345
360
|
}
|
|
346
361
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
362
|
+
/** Each applied file's hash, the first recorded kept: one statement per 33 files, not one per file (p0.11 row 16). */
|
|
363
|
+
async recordApplied(files: readonly (readonly [file: string, sha256: string])[]): Promise<void> {
|
|
364
|
+
const t = this.now();
|
|
365
|
+
for (let i = 0; i < files.length; i += APPLIED_ROWS) {
|
|
366
|
+
const chunk = files.slice(i, i + APPLIED_ROWS);
|
|
367
|
+
await this.q(
|
|
368
|
+
`INSERT INTO applied (file, sha256, applied_at) VALUES ${chunk.map(() => "(?, ?, ?)").join(", ")} ON CONFLICT (file) DO NOTHING`,
|
|
369
|
+
chunk.flatMap(([f, h]) => [f, h, t]),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
353
372
|
}
|
|
354
373
|
|
|
355
374
|
// ---- the store's own resources (§6.1) ------------------------------------------------------------------
|
package/src/preview.ts
CHANGED
|
@@ -200,6 +200,7 @@ export function previewConfig(source: WranglerJson, p: PreviewWorker): Record<st
|
|
|
200
200
|
workers_dev: true,
|
|
201
201
|
observability: {
|
|
202
202
|
...observability,
|
|
203
|
+
redact_query_string: true,
|
|
203
204
|
logs: { ...((observability.logs as object | undefined) ?? {}), invocation_logs: false },
|
|
204
205
|
},
|
|
205
206
|
vars: { ...vars, ADMIN_ORIGINS: [...origins].join(","), KEDGE_ENVIRONMENT: "preview" },
|
|
@@ -720,6 +721,10 @@ class PreviewRun {
|
|
|
720
721
|
// recreates under a new nonce, where a late landing can't replace it, and the sweep removes
|
|
721
722
|
// what lands on the old name (codex code r4).
|
|
722
723
|
if (e instanceof UnknownOutcome) await this.write("state = 'destroying', lost_create = 1", []);
|
|
724
|
+
// A host that serves one Worker per store refuses this upload (salve hosting), after the preview's
|
|
725
|
+
// database and queue exist: the way out is named, never inferred from the platform's words (p0.11 row 8).
|
|
726
|
+
if (e instanceof Error)
|
|
727
|
+
e.message += `\nThe preview's database and queue exist: remove what it made with kedge preview destroy --name ${this.name}. A host that serves one Worker per store (salve hosting) has no previews: test on kedge dev instead.`;
|
|
723
728
|
throw e;
|
|
724
729
|
} finally {
|
|
725
730
|
if (secretsFile) rmSync(secretsFile, { force: true });
|
package/src/store-api.ts
CHANGED
|
@@ -18,11 +18,18 @@ export async function storeRequest(
|
|
|
18
18
|
const write = (init.method ?? "GET") !== "GET";
|
|
19
19
|
let r: Response;
|
|
20
20
|
try {
|
|
21
|
-
|
|
21
|
+
// No redirect is followed (p0.11 §3): a session is bound to the origin it was sent to, and the
|
|
22
|
+
// person names the store's URL, not something in front of it that sends the request elsewhere.
|
|
23
|
+
r = await f(url, { ...init, redirect: "manual" });
|
|
22
24
|
} catch {
|
|
23
25
|
if (write) throw new UnknownOutcome(what, "the store");
|
|
24
26
|
throw new DeployRefused(`${what}: the store didn't answer`);
|
|
25
27
|
}
|
|
28
|
+
// Kedge's API never redirects, so this answer came from in front of the Worker, which ran nothing.
|
|
29
|
+
if (r.status >= 300 && r.status < 400)
|
|
30
|
+
throw new DeployRefused(
|
|
31
|
+
`${what} answered a redirect${r.headers.get("location") ? ` to ${r.headers.get("location")}` : ""}: the CLI follows none, so give the store's own URL`,
|
|
32
|
+
);
|
|
26
33
|
let body: Record<string, unknown>;
|
|
27
34
|
try {
|
|
28
35
|
body = (await r.json()) as Record<string, unknown>;
|
package/src/store.ts
CHANGED
|
@@ -13,6 +13,17 @@ import { byCodePoint } from "./order";
|
|
|
13
13
|
/** The starter's id until a first deploy writes the real one (§6.1). */
|
|
14
14
|
export const PLACEHOLDER_ID = "00000000-0000-0000-0000-000000000000";
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* wrangler.jsonc with `id` written over the placeholder `database_id`, and
|
|
18
|
+
* only there: `preview_database_id` keeps the zeros, because Wrangler keys the
|
|
19
|
+
* local database by it, and rewriting it would open an empty local store after
|
|
20
|
+
* the first deploy (p0.11 row 17). The opening quote keeps the pattern off
|
|
21
|
+
* `"preview_database_id"`, so the keys' order doesn't matter.
|
|
22
|
+
*/
|
|
23
|
+
export function withDatabaseId(text: string, id: string): string {
|
|
24
|
+
return text.replace(new RegExp(`("database_id"\\s*:\\s*")${PLACEHOLDER_ID}"`), `$1${id}"`);
|
|
25
|
+
}
|
|
26
|
+
|
|
16
27
|
export interface StoreNames {
|
|
17
28
|
worker: string;
|
|
18
29
|
database: string;
|
|
@@ -24,7 +35,13 @@ export interface StoreNames {
|
|
|
24
35
|
export type WranglerJson = Record<string, unknown> & {
|
|
25
36
|
name?: string;
|
|
26
37
|
main?: string;
|
|
27
|
-
d1_databases?: {
|
|
38
|
+
d1_databases?: {
|
|
39
|
+
binding?: string;
|
|
40
|
+
database_name?: string;
|
|
41
|
+
database_id?: string;
|
|
42
|
+
preview_database_id?: string;
|
|
43
|
+
migrations_dir?: string;
|
|
44
|
+
}[];
|
|
28
45
|
queues?: { producers?: { binding?: string; queue?: string }[]; consumers?: { queue?: string }[] };
|
|
29
46
|
migrations?: Record<string, unknown>[];
|
|
30
47
|
triggers?: { crons?: string[] };
|
|
@@ -75,14 +92,19 @@ export function git(cwd: string): Git {
|
|
|
75
92
|
if (r.status !== 0) throw new Error("kedge deploy needs a Git repository with a commit");
|
|
76
93
|
return r.stdout.trim();
|
|
77
94
|
},
|
|
95
|
+
// A store may sit below its repository's root (create-kedge reuses a workspace's repository, p0.11
|
|
96
|
+
// row 12): status covers the store's own directory, and its paths are the store's, as `show` reads
|
|
97
|
+
// them (codex code r5).
|
|
78
98
|
dirty() {
|
|
79
|
-
|
|
99
|
+
const prefix = run(["rev-parse", "--show-prefix"]).stdout.trim();
|
|
100
|
+
return run(["status", "--porcelain", "--", "."])
|
|
80
101
|
.stdout.split("\n")
|
|
81
102
|
.filter(Boolean)
|
|
82
|
-
.map((l) => l.slice(3))
|
|
103
|
+
.map((l) => l.slice(3))
|
|
104
|
+
.map((p) => (p.startsWith(prefix) ? p.slice(prefix.length) : p));
|
|
83
105
|
},
|
|
84
106
|
show(path) {
|
|
85
|
-
const r = run(["show", `HEAD
|
|
107
|
+
const r = run(["show", `HEAD:./${path}`]);
|
|
86
108
|
return r.status === 0 ? r.stdout : null;
|
|
87
109
|
},
|
|
88
110
|
isAncestor(older, newer) {
|
|
@@ -100,7 +122,7 @@ export function dirtyBeyondId(cwd: string, g: Git, databaseId: string | null): s
|
|
|
100
122
|
return g.dirty().filter((p) => {
|
|
101
123
|
if (p !== "wrangler.jsonc" || !databaseId) return true;
|
|
102
124
|
const head = g.show("wrangler.jsonc");
|
|
103
|
-
return head === null || head
|
|
125
|
+
return head === null || withDatabaseId(head, databaseId) !== readFileSync(join(cwd, p), "utf8");
|
|
104
126
|
});
|
|
105
127
|
}
|
|
106
128
|
|
package/src/target.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Which store a URL is (docs/proposals/p0.10-agent-path.md §4, §6.2). The CLI
|
|
2
2
|
// sends a session, a saved key or a run's record only to a target it can name
|
|
3
3
|
// by identity: production or a preview this store's `<store>-ops` records, or
|
|
4
|
-
// `kedge dev` on this machine.
|
|
5
|
-
//
|
|
4
|
+
// `kedge dev` on this machine. Any other https URL may be production under
|
|
5
|
+
// another name (salve hosting's, a custom domain), and is proved before it
|
|
6
|
+
// gets anything but the proof's own session, bound to that URL's origin and so
|
|
7
|
+
// useless anywhere else (p0.11 §3). A store is its account, Worker and
|
|
6
8
|
// database, not its URL: the account's owner can change the workers.dev
|
|
7
9
|
// subdomain, so the URL is computed from the current one on every run.
|
|
8
|
-
import type
|
|
10
|
+
import { type Cloudflare, NeedsPerson } from "./cf";
|
|
9
11
|
import { openOps } from "./deploy";
|
|
10
12
|
import { storeNames } from "./store";
|
|
11
13
|
|
|
@@ -17,6 +19,12 @@ export interface StoreTarget {
|
|
|
17
19
|
identity: { account: string; worker: string; database: string };
|
|
18
20
|
/** Where a CLI session's row is written: that target's own database, and no other. */
|
|
19
21
|
database: { name: string; id: string | null };
|
|
22
|
+
/**
|
|
23
|
+
* A URL the person gave rather than one the CLI derived (salve's, a custom
|
|
24
|
+
* domain): it's proved to be this store before any other request goes there
|
|
25
|
+
* (p0.11 §3).
|
|
26
|
+
*/
|
|
27
|
+
proof?: true;
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/**
|
|
@@ -55,8 +63,7 @@ export async function resolveTarget(
|
|
|
55
63
|
if (!(await cf.findD1(names.ops))) return null;
|
|
56
64
|
const { ops } = await openOps(cf, names);
|
|
57
65
|
const host = (worker: string, subdomain: string) => `https://${worker}.${subdomain}.workers.dev`;
|
|
58
|
-
const
|
|
59
|
-
if (u.origin === host(names.worker, subdomain)) {
|
|
66
|
+
const production = async (proof: boolean): Promise<StoreTarget | null> => {
|
|
60
67
|
const db = await ops.resource("d1", names.database);
|
|
61
68
|
if (!db?.id) return null;
|
|
62
69
|
return {
|
|
@@ -64,14 +71,35 @@ export async function resolveTarget(
|
|
|
64
71
|
url: u.origin,
|
|
65
72
|
identity: { account: cf.account, worker: names.worker, database: db.id },
|
|
66
73
|
database: { name: names.database, id: db.id },
|
|
74
|
+
...(proof ? { proof: true as const } : {}),
|
|
67
75
|
};
|
|
76
|
+
};
|
|
77
|
+
// Derived only where the Worker is served at workers.dev: a platform's stand-in subdomain (salve's) names
|
|
78
|
+
// a host the store isn't on, which then gets the proof like any other URL (p0.11 row 2).
|
|
79
|
+
// A store served only at its own domain, on an account without a subdomain, still resolves (codex code r3).
|
|
80
|
+
const devUrl = await cf.workersDevUrl(names.worker).catch((e: unknown) => {
|
|
81
|
+
if (e instanceof NeedsPerson) return null;
|
|
82
|
+
throw e;
|
|
83
|
+
});
|
|
84
|
+
if (u.origin === devUrl) return production(false);
|
|
85
|
+
// A preview is only ever at workers.dev, so only such a host asks for the account's subdomain. The whole
|
|
86
|
+
// origin, as a preview's is made: a host that only begins with a preview's name isn't it.
|
|
87
|
+
let p: Record<string, unknown> | undefined;
|
|
88
|
+
if (u.hostname.endsWith(".workers.dev")) {
|
|
89
|
+
const subdomain = await cf.workersSubdomain().catch((e: unknown) => {
|
|
90
|
+
if (e instanceof NeedsPerson) return null;
|
|
91
|
+
throw e;
|
|
92
|
+
});
|
|
93
|
+
const rows = subdomain
|
|
94
|
+
? await ops.query(
|
|
95
|
+
"SELECT worker, database_name, database_id FROM preview WHERE state = 'bootstrapped' AND database_id IS NOT NULL",
|
|
96
|
+
)
|
|
97
|
+
: [];
|
|
98
|
+
p = rows.find((r) => u.origin === host(String(r.worker), subdomain as string));
|
|
68
99
|
}
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
);
|
|
73
|
-
const p = rows.find((r) => u.origin === host(String(r.worker), subdomain));
|
|
74
|
-
if (!p) return null;
|
|
100
|
+
// Any other https origin may be production under another name (salve hosting's, a custom domain):
|
|
101
|
+
// the proof decides, before a session's token goes there (p0.11 row 1).
|
|
102
|
+
if (!p) return production(true);
|
|
75
103
|
return {
|
|
76
104
|
kind: "preview",
|
|
77
105
|
url: u.origin,
|