@wtfalch/foundry 0.65.0 → 0.67.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/dist/apps/cloudflare.d.ts +5 -0
- package/dist/apps/deploy.js +33 -1
- package/dist/apps/migrations.d.ts +3 -0
- package/dist/apps/migrations.js +32 -7
- package/dist/catalogue/copy-en.js +5 -0
- package/dist/catalogue/integrations.js +5 -0
- package/dist/entry-rules.d.ts +11 -0
- package/dist/entry-rules.js +70 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0069_release_events.sql +16 -0
- package/dist/modules/analytics.d.ts +8 -0
- package/dist/modules/analytics.js +28 -0
- package/dist/modules/database.js +22 -3
- package/dist/modules/index.js +2 -0
- package/dist/native-build.d.ts +16 -12
- package/dist/native-build.js +16 -12
- package/dist/org.d.ts +9 -1
- package/dist/org.js +4 -1
- package/dist/plan/analytics-boule.d.ts +17 -0
- package/dist/plan/analytics-boule.js +43 -0
- package/dist/plan/analytics.d.ts +58 -0
- package/dist/plan/analytics.js +89 -0
- package/dist/plan/boule.js +2 -0
- package/dist/plan/company-backups.js +2 -0
- package/dist/plan/diff.d.ts +4 -0
- package/dist/plan/diff.js +103 -3
- package/dist/plan/entry-analytics-key.d.ts +35 -0
- package/dist/plan/entry-analytics-key.js +134 -0
- package/dist/plan/executor.d.ts +79 -6
- package/dist/plan/executor.js +379 -19
- package/dist/plan/hostnames.js +4 -0
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.js +3 -0
- package/dist/plan/login-host.d.ts +8 -0
- package/dist/plan/login-host.js +8 -0
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +3 -0
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/plan-apply.d.ts +12 -0
- package/dist/plan/plan-apply.js +40 -1
- package/dist/plan/platform-diff.d.ts +17 -1
- package/dist/plan/platform-diff.js +40 -2
- package/dist/plan/platform-org.d.ts +6 -0
- package/dist/plan/platform-providers.d.ts +8 -6
- package/dist/plan/platform-providers.js +56 -10
- package/dist/plan/providers-readers.js +16 -1
- package/dist/plan/release-host.d.ts +30 -1
- package/dist/plan/release-host.js +84 -41
- package/dist/plan/release-series-reader.d.ts +26 -0
- package/dist/plan/release-series-reader.js +40 -0
- package/dist/plan/sign-in-archon.d.ts +1 -1
- package/dist/plan/sign-in-archon.js +2 -2
- package/dist/plan/stage-services.d.ts +3 -1
- package/dist/plan/stage-services.js +2 -1
- package/dist/plan/uptime-worker.js +53 -3
- package/dist/plan/worker-app-database.d.ts +12 -1
- package/dist/plan/worker-app-database.js +13 -0
- package/dist/plan/worker-app.d.ts +27 -2
- package/dist/plan/worker-app.js +52 -7
- package/dist/plan/workspace.d.ts +2 -2
- package/dist/plan/workspace.js +3 -3
- package/dist/release/decide.d.ts +2 -0
- package/dist/release/decide.js +67 -10
- package/dist/release/events.d.ts +13 -0
- package/dist/release/events.js +67 -0
- package/dist/release/health.d.ts +5 -0
- package/dist/release/health.js +3 -3
- package/dist/release/run.d.ts +7 -2
- package/dist/release/run.js +8 -0
- package/dist/release/series.d.ts +58 -0
- package/dist/release/series.js +65 -0
- package/dist/release/state.d.ts +58 -1
- package/dist/release/state.js +45 -0
- package/dist/release/types.d.ts +44 -0
- package/dist/slug.d.ts +1 -1
- package/dist/slug.js +5 -2
- package/dist/store/postgres.js +29 -3
- package/dist/store/schema.d.ts +112 -0
- package/dist/store/schema.js +9 -0
- package/dist/worker-app-config.d.ts +22 -1
- package/dist/worker-app-config.js +73 -15
- package/dist/worker-app-migrations.d.ts +6 -2
- package/dist/worker-app-migrations.js +1 -0
- package/package.json +1 -1
package/dist/release/state.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { type Recorded, resolveReleaseConfig } from './decide.js';
|
|
3
|
-
import type { Color, ReleaseAction, ReleaseSignal, ReleaseState, StageCopyState, WindowState } from './types.js';
|
|
3
|
+
import type { Color, ReleaseAction, ReleaseEvent, ReleaseSignal, ReleaseState, StageCopyState, WindowState } from './types.js';
|
|
4
4
|
/**
|
|
5
5
|
* The persistence the release runner and Archon share. `getScopeIds` is the
|
|
6
6
|
* recorded `bg.*` keys of an org's applied state (or of the platform, for the
|
|
@@ -26,6 +26,19 @@ export interface ReleaseStore {
|
|
|
26
26
|
}[]>;
|
|
27
27
|
/** Marks requests consumed. The runner calls it only after its decision is persisted. */
|
|
28
28
|
consumeRequests(ids: readonly number[]): Promise<void>;
|
|
29
|
+
/** Appends history lines (`foundry_release_events`), oldest first. Nothing is ever updated or deleted. */
|
|
30
|
+
appendEvents(input: {
|
|
31
|
+
orgId: string;
|
|
32
|
+
entryKey: string;
|
|
33
|
+
events: readonly ReleaseEvent[];
|
|
34
|
+
}): Promise<void>;
|
|
35
|
+
/** The history of one entry, newest first. `since` is inclusive. Rows that no longer parse are skipped. */
|
|
36
|
+
readEvents(input: {
|
|
37
|
+
orgId: string;
|
|
38
|
+
entryKey: string;
|
|
39
|
+
since?: string;
|
|
40
|
+
limit: number;
|
|
41
|
+
}): Promise<ReleaseEvent[]>;
|
|
29
42
|
}
|
|
30
43
|
/** Scope keys (C3). `entry` is a hosting entry key: never `.` or `:`. */
|
|
31
44
|
export declare function releaseKeys(entry: string): {
|
|
@@ -53,6 +66,41 @@ export declare function releaseKeys(entry: string): {
|
|
|
53
66
|
};
|
|
54
67
|
export declare function formatWeights(w: Record<Color, number>): string;
|
|
55
68
|
export declare function parseWeights(value: string | undefined): Record<Color, number> | null;
|
|
69
|
+
export declare const RELEASE_EVENT_KINDS: readonly ["promote", "step", "check", "pause", "resume", "step_now", "approve", "open", "rollback", "passed", "hard_fail"];
|
|
70
|
+
/** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
|
|
71
|
+
export declare const releaseEventPayload: z.ZodObject<{
|
|
72
|
+
percent: z.ZodOptional<z.ZodNumber>;
|
|
73
|
+
actor: z.ZodOptional<z.ZodString>;
|
|
74
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
75
|
+
release: z.ZodOptional<z.ZodObject<{
|
|
76
|
+
from: z.ZodString;
|
|
77
|
+
to: z.ZodString;
|
|
78
|
+
}, z.core.$strip>>;
|
|
79
|
+
signal: z.ZodOptional<z.ZodObject<{
|
|
80
|
+
requests: z.ZodNumber;
|
|
81
|
+
errors: z.ZodNumber;
|
|
82
|
+
baselineRate: z.ZodNullable<z.ZodNumber>;
|
|
83
|
+
verdict: z.ZodEnum<{
|
|
84
|
+
inconclusive: "inconclusive";
|
|
85
|
+
pass: "pass";
|
|
86
|
+
fail: "fail";
|
|
87
|
+
hard_fail: "hard_fail";
|
|
88
|
+
}>;
|
|
89
|
+
}, z.core.$strip>>;
|
|
90
|
+
}, z.core.$strip>;
|
|
91
|
+
export declare const releaseEventKind: z.ZodEnum<{
|
|
92
|
+
step: "step";
|
|
93
|
+
open: "open";
|
|
94
|
+
check: "check";
|
|
95
|
+
passed: "passed";
|
|
96
|
+
hard_fail: "hard_fail";
|
|
97
|
+
promote: "promote";
|
|
98
|
+
rollback: "rollback";
|
|
99
|
+
pause: "pause";
|
|
100
|
+
resume: "resume";
|
|
101
|
+
step_now: "step_now";
|
|
102
|
+
approve: "approve";
|
|
103
|
+
}>;
|
|
56
104
|
/** The window JSON is the C2 state plus the active release's `release` field. */
|
|
57
105
|
export declare function parseWindow(value: string | undefined): {
|
|
58
106
|
window: WindowState;
|
|
@@ -86,3 +134,12 @@ export declare function requestReleaseAction(store: ReleaseStore, input: {
|
|
|
86
134
|
actor: string;
|
|
87
135
|
}): Promise<void>;
|
|
88
136
|
export { actionSchema as releaseActionSchema };
|
|
137
|
+
export declare const RELEASE_HISTORY_DEFAULT_LIMIT = 200;
|
|
138
|
+
export declare const RELEASE_HISTORY_MAX_LIMIT = 1000;
|
|
139
|
+
/** C1: the history of one entry (a release's steps, checks, actions and outcome), newest first. */
|
|
140
|
+
export declare function readReleaseHistory(store: Pick<ReleaseStore, 'readEvents'>, q: {
|
|
141
|
+
orgId: string;
|
|
142
|
+
entryKey: string;
|
|
143
|
+
since?: string;
|
|
144
|
+
limit?: number;
|
|
145
|
+
}): Promise<ReleaseEvent[]>;
|
package/dist/release/state.js
CHANGED
|
@@ -48,6 +48,7 @@ const storedWindow = z.discriminatedUnion('kind', [
|
|
|
48
48
|
z.object({ kind: z.literal('rolled_back'), at: z.string(), reason: z.string() }),
|
|
49
49
|
z.object({ kind: z.literal('cap'), since: z.string() }),
|
|
50
50
|
z.object({ kind: z.literal('staff'), since: z.string() }),
|
|
51
|
+
z.object({ kind: z.literal('paused'), percent: z.number(), since: z.string() }),
|
|
51
52
|
]);
|
|
52
53
|
const releaseField = z
|
|
53
54
|
.object({
|
|
@@ -65,6 +66,28 @@ const signalSchema = z.object({
|
|
|
65
66
|
baselineRate: z.number().nullable(),
|
|
66
67
|
verdict: z.enum(['pass', 'fail', 'hard_fail', 'inconclusive']),
|
|
67
68
|
});
|
|
69
|
+
export const RELEASE_EVENT_KINDS = [
|
|
70
|
+
'promote',
|
|
71
|
+
'step',
|
|
72
|
+
'check',
|
|
73
|
+
'pause',
|
|
74
|
+
'resume',
|
|
75
|
+
'step_now',
|
|
76
|
+
'approve',
|
|
77
|
+
'open',
|
|
78
|
+
'rollback',
|
|
79
|
+
'passed',
|
|
80
|
+
'hard_fail',
|
|
81
|
+
];
|
|
82
|
+
/** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
|
|
83
|
+
export const releaseEventPayload = z.object({
|
|
84
|
+
percent: z.number().optional(),
|
|
85
|
+
actor: z.string().optional(),
|
|
86
|
+
reason: z.string().optional(),
|
|
87
|
+
release: z.object({ from: z.string(), to: z.string() }).optional(),
|
|
88
|
+
signal: signalSchema.optional(),
|
|
89
|
+
});
|
|
90
|
+
export const releaseEventKind = z.enum(RELEASE_EVENT_KINDS);
|
|
68
91
|
function parseJson(value) {
|
|
69
92
|
if (!value)
|
|
70
93
|
return undefined;
|
|
@@ -187,6 +210,9 @@ const actionSchema = z.discriminatedUnion('kind', [
|
|
|
187
210
|
z.object({ kind: z.literal('decide_at_cap'), decision: z.enum(['pass', 'rollback']) }),
|
|
188
211
|
z.object({ kind: z.literal('approve_step') }),
|
|
189
212
|
z.object({ kind: z.literal('open') }),
|
|
213
|
+
z.object({ kind: z.literal('pause') }),
|
|
214
|
+
z.object({ kind: z.literal('resume') }),
|
|
215
|
+
z.object({ kind: z.literal('step_now') }),
|
|
190
216
|
z.object({ kind: z.literal('deploy_stage'), ref: z.string().regex(STAGE_REF_PATTERN) }),
|
|
191
217
|
z.object({ kind: z.literal('copy_prod_to_stage') }),
|
|
192
218
|
z.object({ kind: z.literal('undo_stage_copy') }),
|
|
@@ -197,3 +223,22 @@ export async function requestReleaseAction(store, input) {
|
|
|
197
223
|
await store.addRequest({ ...input, action: actionSchema.parse(input.action) });
|
|
198
224
|
}
|
|
199
225
|
export { actionSchema as releaseActionSchema };
|
|
226
|
+
export const RELEASE_HISTORY_DEFAULT_LIMIT = 200;
|
|
227
|
+
export const RELEASE_HISTORY_MAX_LIMIT = 1000;
|
|
228
|
+
/** C1: the history of one entry (a release's steps, checks, actions and outcome), newest first. */
|
|
229
|
+
export async function readReleaseHistory(store, q) {
|
|
230
|
+
releaseKeys(q.entryKey);
|
|
231
|
+
if (q.since !== undefined && Number.isNaN(Date.parse(q.since))) {
|
|
232
|
+
throw new Error('invalid release history start time');
|
|
233
|
+
}
|
|
234
|
+
const wanted = q.limit !== undefined && Number.isFinite(q.limit)
|
|
235
|
+
? Math.floor(q.limit)
|
|
236
|
+
: RELEASE_HISTORY_DEFAULT_LIMIT;
|
|
237
|
+
const limit = Math.min(Math.max(wanted, 1), RELEASE_HISTORY_MAX_LIMIT);
|
|
238
|
+
return store.readEvents({
|
|
239
|
+
orgId: q.orgId,
|
|
240
|
+
entryKey: q.entryKey,
|
|
241
|
+
...(q.since !== undefined ? { since: q.since } : {}),
|
|
242
|
+
limit,
|
|
243
|
+
});
|
|
244
|
+
}
|
package/dist/release/types.d.ts
CHANGED
|
@@ -27,6 +27,12 @@ export type WindowState = {
|
|
|
27
27
|
| {
|
|
28
28
|
kind: 'staff';
|
|
29
29
|
since: string;
|
|
30
|
+
}
|
|
31
|
+
/** A person held the step with `pause`. `since` is when the step began; `resume` returns to `step` with it. */
|
|
32
|
+
| {
|
|
33
|
+
kind: 'paused';
|
|
34
|
+
percent: number;
|
|
35
|
+
since: string;
|
|
30
36
|
};
|
|
31
37
|
export interface ReleaseSignal {
|
|
32
38
|
requests: number;
|
|
@@ -88,6 +94,12 @@ export type ReleaseAction = {
|
|
|
88
94
|
kind: 'approve_step';
|
|
89
95
|
} | {
|
|
90
96
|
kind: 'open';
|
|
97
|
+
} | {
|
|
98
|
+
kind: 'pause';
|
|
99
|
+
} | {
|
|
100
|
+
kind: 'resume';
|
|
101
|
+
} | {
|
|
102
|
+
kind: 'step_now';
|
|
91
103
|
} | {
|
|
92
104
|
kind: 'deploy_stage';
|
|
93
105
|
ref: string;
|
|
@@ -96,6 +108,38 @@ export type ReleaseAction = {
|
|
|
96
108
|
} | {
|
|
97
109
|
kind: 'undo_stage_copy';
|
|
98
110
|
};
|
|
111
|
+
export type ReleaseEventKind = 'promote' | 'step' | 'check' | 'pause' | 'resume' | 'step_now' | 'approve' | 'open' | 'rollback' | 'passed' | 'hard_fail';
|
|
112
|
+
/** C1: one line of a release's history, written by the release run (`foundry_release_events`). */
|
|
113
|
+
export interface ReleaseEvent {
|
|
114
|
+
at: string;
|
|
115
|
+
kind: ReleaseEventKind;
|
|
116
|
+
percent?: number;
|
|
117
|
+
/** Who asked, when a person did. */
|
|
118
|
+
actor?: string;
|
|
119
|
+
reason?: string;
|
|
120
|
+
release?: {
|
|
121
|
+
from: string;
|
|
122
|
+
to: string;
|
|
123
|
+
};
|
|
124
|
+
signal?: ReleaseSignal;
|
|
125
|
+
}
|
|
126
|
+
/** C2: what reporting counts for one color in one bucket. */
|
|
127
|
+
export interface ReleaseSeriesColor {
|
|
128
|
+
requests: number;
|
|
129
|
+
errors: number;
|
|
130
|
+
slow: number;
|
|
131
|
+
durationMsSum: number;
|
|
132
|
+
signIns: number;
|
|
133
|
+
signInFailures: number;
|
|
134
|
+
}
|
|
135
|
+
/** C2: reporting's `GET /api/reporting/release-series` body. */
|
|
136
|
+
export interface ReleaseSeries {
|
|
137
|
+
bucketMinutes: number;
|
|
138
|
+
buckets: Array<{
|
|
139
|
+
start: string;
|
|
140
|
+
colors: Record<Color, ReleaseSeriesColor>;
|
|
141
|
+
}>;
|
|
142
|
+
}
|
|
99
143
|
/** The requests that run a stage copy. They are handled by the tick's second phase, never by an apply. */
|
|
100
144
|
export declare const isStageCopyAction: (action: ReleaseAction) => boolean;
|
|
101
145
|
export declare const COLORS: readonly Color[];
|
package/dist/slug.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export declare const RESERVED_SLUGS: ReadonlySet<string>;
|
|
3
3
|
/** Every platform app, tunnel, project and database name starts `platform-`, so no company slug may. */
|
|
4
4
|
export declare const RESERVED_SLUG_PREFIX = "platform-";
|
|
5
|
-
/** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
|
|
5
|
+
/** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-analytics`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
|
|
6
6
|
export declare function hasBarredSlugSuffix(label: string): boolean;
|
|
7
7
|
/**
|
|
8
8
|
* Why {@link validateSlug} refused a candidate slug, by name so a caller —
|
package/dist/slug.js
CHANGED
|
@@ -146,6 +146,8 @@ const BOULE_SUFFIX = /-boule$/;
|
|
|
146
146
|
* hostname once kosmos is on, not because every org gets one.
|
|
147
147
|
*/
|
|
148
148
|
const KOSMOS_SUFFIX = /-kosmos$/;
|
|
149
|
+
/** `<something>-analytics` — a level-1 org's own analytics backend hostname (`<slug>-analytics.oikos.host`, foundry#724). Reserved always, so a company that turns analytics on later never finds the name taken. */
|
|
150
|
+
const ANALYTICS_SUFFIX = /-analytics$/;
|
|
149
151
|
/** `<something>-auth|-mail|-files|-sfu|-cms` — an own company's service hostnames on oikos.host (`plan/own-hostnames.ts`). */
|
|
150
152
|
const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
|
|
151
153
|
/**
|
|
@@ -159,7 +161,7 @@ const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
|
|
|
159
161
|
* `names-`.
|
|
160
162
|
*/
|
|
161
163
|
const NAMES_PREFIX = /^names(-|$)/;
|
|
162
|
-
/** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
|
|
164
|
+
/** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-analytics`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
|
|
163
165
|
export function hasBarredSlugSuffix(label) {
|
|
164
166
|
return (STAGE_SUFFIX.test(label) ||
|
|
165
167
|
PR_SUFFIX.test(label) ||
|
|
@@ -167,6 +169,7 @@ export function hasBarredSlugSuffix(label) {
|
|
|
167
169
|
AGORA_SUFFIX.test(label) ||
|
|
168
170
|
BOULE_SUFFIX.test(label) ||
|
|
169
171
|
KOSMOS_SUFFIX.test(label) ||
|
|
172
|
+
ANALYTICS_SUFFIX.test(label) ||
|
|
170
173
|
OWN_SERVICE_SUFFIX.test(label));
|
|
171
174
|
}
|
|
172
175
|
/**
|
|
@@ -198,7 +201,7 @@ export function validateSlug(slug) {
|
|
|
198
201
|
if (hasBarredSlugSuffix(slug)) {
|
|
199
202
|
return {
|
|
200
203
|
reason: 'environment-suffix',
|
|
201
|
-
message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-kosmos', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161, #949) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, 'acme-kosmos' only as acme's kosmos hostname, never as an org slug in its own right`,
|
|
204
|
+
message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-kosmos', '-analytics', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161, #949) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, 'acme-kosmos' only as acme's kosmos hostname, never as an org slug in its own right`,
|
|
202
205
|
};
|
|
203
206
|
}
|
|
204
207
|
return null;
|
package/dist/store/postgres.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { and, asc, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
|
|
1
|
+
import { and, asc, desc, eq, gte, inArray, isNull, ne, sql } from 'drizzle-orm';
|
|
2
2
|
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, normalizeHostingRepos, refuseBuiltinBlueGreen, refuseCustomers, refuseHosting, validateBlueGreen, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
|
|
3
3
|
import { hostingNames } from '../plan/locked-hostnames.js';
|
|
4
4
|
import { findNameClashes, nameClashMessage } from '../plan/org-names.js';
|
|
5
5
|
import { validatePlatform } from '../platform.js';
|
|
6
|
-
import { releaseActionSchema } from '../release/state.js';
|
|
6
|
+
import { releaseActionSchema, releaseEventKind, releaseEventPayload, } from '../release/state.js';
|
|
7
7
|
import { RequestedChangeError, classifyChange } from '../requested-changes.js';
|
|
8
8
|
import { validateSlug } from '../slug.js';
|
|
9
9
|
import { StaleLeaseError, } from '../store.js';
|
|
10
|
-
import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryMutationLeases, foundryReleaseRequests, foundryRequestedChanges, } from './schema.js';
|
|
10
|
+
import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryMutationLeases, foundryReleaseEvents, foundryReleaseRequests, foundryRequestedChanges, } from './schema.js';
|
|
11
11
|
const UNIQUE_VIOLATION = '23505';
|
|
12
12
|
/**
|
|
13
13
|
* #441: runs `write`, and with a `fence` only if its token is still the
|
|
@@ -1100,5 +1100,31 @@ export function createPostgresReleaseStore(db) {
|
|
|
1100
1100
|
.set({ consumedAt: sql `now()` })
|
|
1101
1101
|
.where(and(inArray(foundryReleaseRequests.id, [...ids]), isNull(foundryReleaseRequests.consumedAt)));
|
|
1102
1102
|
},
|
|
1103
|
+
async appendEvents({ orgId, entryKey, events }) {
|
|
1104
|
+
if (events.length === 0)
|
|
1105
|
+
return;
|
|
1106
|
+
await db.insert(foundryReleaseEvents).values(events.map(({ at, kind, ...payload }) => ({
|
|
1107
|
+
orgId,
|
|
1108
|
+
entryKey,
|
|
1109
|
+
at: new Date(at),
|
|
1110
|
+
kind,
|
|
1111
|
+
payload,
|
|
1112
|
+
})));
|
|
1113
|
+
},
|
|
1114
|
+
async readEvents({ orgId, entryKey, since, limit }) {
|
|
1115
|
+
const rows = await db
|
|
1116
|
+
.select()
|
|
1117
|
+
.from(foundryReleaseEvents)
|
|
1118
|
+
.where(and(eq(foundryReleaseEvents.orgId, orgId), eq(foundryReleaseEvents.entryKey, entryKey), ...(since ? [gte(foundryReleaseEvents.at, new Date(since))] : [])))
|
|
1119
|
+
.orderBy(desc(foundryReleaseEvents.at), desc(foundryReleaseEvents.id))
|
|
1120
|
+
.limit(limit);
|
|
1121
|
+
return rows.flatMap((row) => {
|
|
1122
|
+
const kind = releaseEventKind.safeParse(row.kind);
|
|
1123
|
+
const payload = releaseEventPayload.safeParse(row.payload);
|
|
1124
|
+
return kind.success && payload.success
|
|
1125
|
+
? [{ at: row.at.toISOString(), kind: kind.data, ...payload.data }]
|
|
1126
|
+
: [];
|
|
1127
|
+
});
|
|
1128
|
+
},
|
|
1103
1129
|
};
|
|
1104
1130
|
}
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -2854,6 +2854,118 @@ export declare const foundryReleaseRequests: import("drizzle-orm/pg-core").PgTab
|
|
|
2854
2854
|
};
|
|
2855
2855
|
dialect: "pg";
|
|
2856
2856
|
}>;
|
|
2857
|
+
/** Mirrors `migrations/0069_release_events.sql`: a release's history, appended by the release run and never changed. */
|
|
2858
|
+
export declare const foundryReleaseEvents: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
2859
|
+
name: "foundry_release_events";
|
|
2860
|
+
schema: undefined;
|
|
2861
|
+
columns: {
|
|
2862
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
2863
|
+
name: "id";
|
|
2864
|
+
tableName: "foundry_release_events";
|
|
2865
|
+
dataType: "number";
|
|
2866
|
+
columnType: "PgBigSerial53";
|
|
2867
|
+
data: number;
|
|
2868
|
+
driverParam: number;
|
|
2869
|
+
notNull: true;
|
|
2870
|
+
hasDefault: true;
|
|
2871
|
+
isPrimaryKey: true;
|
|
2872
|
+
isAutoincrement: false;
|
|
2873
|
+
hasRuntimeDefault: false;
|
|
2874
|
+
enumValues: undefined;
|
|
2875
|
+
baseColumn: never;
|
|
2876
|
+
identity: undefined;
|
|
2877
|
+
generated: undefined;
|
|
2878
|
+
}, {}, {}>;
|
|
2879
|
+
orgId: import("drizzle-orm/pg-core").PgColumn<{
|
|
2880
|
+
name: "org_id";
|
|
2881
|
+
tableName: "foundry_release_events";
|
|
2882
|
+
dataType: "string";
|
|
2883
|
+
columnType: "PgText";
|
|
2884
|
+
data: string;
|
|
2885
|
+
driverParam: string;
|
|
2886
|
+
notNull: true;
|
|
2887
|
+
hasDefault: false;
|
|
2888
|
+
isPrimaryKey: false;
|
|
2889
|
+
isAutoincrement: false;
|
|
2890
|
+
hasRuntimeDefault: false;
|
|
2891
|
+
enumValues: [string, ...string[]];
|
|
2892
|
+
baseColumn: never;
|
|
2893
|
+
identity: undefined;
|
|
2894
|
+
generated: undefined;
|
|
2895
|
+
}, {}, {}>;
|
|
2896
|
+
entryKey: import("drizzle-orm/pg-core").PgColumn<{
|
|
2897
|
+
name: "entry_key";
|
|
2898
|
+
tableName: "foundry_release_events";
|
|
2899
|
+
dataType: "string";
|
|
2900
|
+
columnType: "PgText";
|
|
2901
|
+
data: string;
|
|
2902
|
+
driverParam: string;
|
|
2903
|
+
notNull: true;
|
|
2904
|
+
hasDefault: false;
|
|
2905
|
+
isPrimaryKey: false;
|
|
2906
|
+
isAutoincrement: false;
|
|
2907
|
+
hasRuntimeDefault: false;
|
|
2908
|
+
enumValues: [string, ...string[]];
|
|
2909
|
+
baseColumn: never;
|
|
2910
|
+
identity: undefined;
|
|
2911
|
+
generated: undefined;
|
|
2912
|
+
}, {}, {}>;
|
|
2913
|
+
at: import("drizzle-orm/pg-core").PgColumn<{
|
|
2914
|
+
name: "at";
|
|
2915
|
+
tableName: "foundry_release_events";
|
|
2916
|
+
dataType: "date";
|
|
2917
|
+
columnType: "PgTimestamp";
|
|
2918
|
+
data: Date;
|
|
2919
|
+
driverParam: string;
|
|
2920
|
+
notNull: true;
|
|
2921
|
+
hasDefault: false;
|
|
2922
|
+
isPrimaryKey: false;
|
|
2923
|
+
isAutoincrement: false;
|
|
2924
|
+
hasRuntimeDefault: false;
|
|
2925
|
+
enumValues: undefined;
|
|
2926
|
+
baseColumn: never;
|
|
2927
|
+
identity: undefined;
|
|
2928
|
+
generated: undefined;
|
|
2929
|
+
}, {}, {}>;
|
|
2930
|
+
kind: import("drizzle-orm/pg-core").PgColumn<{
|
|
2931
|
+
name: "kind";
|
|
2932
|
+
tableName: "foundry_release_events";
|
|
2933
|
+
dataType: "string";
|
|
2934
|
+
columnType: "PgText";
|
|
2935
|
+
data: string;
|
|
2936
|
+
driverParam: string;
|
|
2937
|
+
notNull: true;
|
|
2938
|
+
hasDefault: false;
|
|
2939
|
+
isPrimaryKey: false;
|
|
2940
|
+
isAutoincrement: false;
|
|
2941
|
+
hasRuntimeDefault: false;
|
|
2942
|
+
enumValues: [string, ...string[]];
|
|
2943
|
+
baseColumn: never;
|
|
2944
|
+
identity: undefined;
|
|
2945
|
+
generated: undefined;
|
|
2946
|
+
}, {}, {}>;
|
|
2947
|
+
payload: import("drizzle-orm/pg-core").PgColumn<{
|
|
2948
|
+
name: "payload";
|
|
2949
|
+
tableName: "foundry_release_events";
|
|
2950
|
+
dataType: "json";
|
|
2951
|
+
columnType: "PgJsonb";
|
|
2952
|
+
data: Record<string, unknown>;
|
|
2953
|
+
driverParam: unknown;
|
|
2954
|
+
notNull: true;
|
|
2955
|
+
hasDefault: true;
|
|
2956
|
+
isPrimaryKey: false;
|
|
2957
|
+
isAutoincrement: false;
|
|
2958
|
+
hasRuntimeDefault: false;
|
|
2959
|
+
enumValues: undefined;
|
|
2960
|
+
baseColumn: never;
|
|
2961
|
+
identity: undefined;
|
|
2962
|
+
generated: undefined;
|
|
2963
|
+
}, {}, {
|
|
2964
|
+
$type: Record<string, unknown>;
|
|
2965
|
+
}>;
|
|
2966
|
+
};
|
|
2967
|
+
dialect: "pg";
|
|
2968
|
+
}>;
|
|
2857
2969
|
export type DeclaredOrgRow = typeof declaredOrgs.$inferSelect;
|
|
2858
2970
|
export type NewDeclaredOrgRow = typeof declaredOrgs.$inferInsert;
|
|
2859
2971
|
export type AppliedStateRow = typeof foundryAppliedState.$inferSelect;
|
package/dist/store/schema.js
CHANGED
|
@@ -268,3 +268,12 @@ export const foundryReleaseRequests = pgTable('foundry_release_requests', {
|
|
|
268
268
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
269
269
|
consumedAt: timestamp('consumed_at', { withTimezone: true }),
|
|
270
270
|
});
|
|
271
|
+
/** Mirrors `migrations/0069_release_events.sql`: a release's history, appended by the release run and never changed. */
|
|
272
|
+
export const foundryReleaseEvents = pgTable('foundry_release_events', {
|
|
273
|
+
id: bigserial('id', { mode: 'number' }).primaryKey(),
|
|
274
|
+
orgId: text('org_id').notNull(),
|
|
275
|
+
entryKey: text('entry_key').notNull(),
|
|
276
|
+
at: timestamp('at', { withTimezone: true }).notNull(),
|
|
277
|
+
kind: text('kind').notNull(),
|
|
278
|
+
payload: jsonb('payload').notNull().$type().default({}),
|
|
279
|
+
});
|
|
@@ -43,12 +43,28 @@ export interface WorkerAppConfig {
|
|
|
43
43
|
* `KEYS_CREDENTIAL`, at most one) delivers a keys custody Worker credential
|
|
44
44
|
* for the app's own host id (`workerAppHostId`), with `KEYS_URL` as a var.
|
|
45
45
|
* `keys` (an issued API key) is refused: custody does not accept one.
|
|
46
|
+
* `heldSecret` is a value the company supplies (a webhook address, a vendor
|
|
47
|
+
* key): it is read from the held-key store at `heldEnvRef(orgId, entryKey, name)`,
|
|
48
|
+
* the ref a `heldSecret` env of a Coolify entry uses, and set on the Worker
|
|
49
|
+
* only while a value is held. With no value the Worker deploys without the
|
|
50
|
+
* secret and the plan says so.
|
|
46
51
|
*/
|
|
47
52
|
secrets?: Record<string, {
|
|
48
53
|
from: 'generated';
|
|
49
54
|
} | {
|
|
50
55
|
from: 'keysCustody';
|
|
56
|
+
} | {
|
|
57
|
+
from: 'heldSecret';
|
|
51
58
|
}>;
|
|
59
|
+
/**
|
|
60
|
+
* Cloudflare cron triggers: at most three standard five-field cron
|
|
61
|
+
* expressions (minute hour day-of-month month day-of-week, UTC), each
|
|
62
|
+
* once. The Worker's `scheduled` handler receives them. Empty or absent
|
|
63
|
+
* clears the triggers.
|
|
64
|
+
*/
|
|
65
|
+
triggers?: {
|
|
66
|
+
crons?: string[];
|
|
67
|
+
};
|
|
52
68
|
/** True delivers the entry's sign-in env as Worker vars. Needs `uses.auth`. */
|
|
53
69
|
authEnv?: boolean;
|
|
54
70
|
/**
|
|
@@ -64,9 +80,14 @@ export interface WorkerAppConfig {
|
|
|
64
80
|
* functions the runtime role may execute, as `name(type, ...)` or
|
|
65
81
|
* `schema.name(type, ...)`: the security definer functions a migration
|
|
66
82
|
* revoked from public, such as `@wtfalch/audit`'s ledger functions.
|
|
83
|
+
* `noDelete` names tables the runtime role may select, insert into and
|
|
84
|
+
* update but never delete from or truncate (a looser `appendOnly`: a table
|
|
85
|
+
* in both lists is refused). The app removes rows from them through a
|
|
86
|
+
* `grants` function, as a retention job does.
|
|
67
87
|
*/
|
|
68
88
|
database?: {
|
|
69
89
|
appendOnly?: string[];
|
|
90
|
+
noDelete?: string[];
|
|
70
91
|
grants?: string[];
|
|
71
92
|
migrationsSha256?: string;
|
|
72
93
|
};
|
|
@@ -80,7 +101,7 @@ export declare const WORKER_APP_COMPATIBILITY_FLAGS: ReadonlySet<string>;
|
|
|
80
101
|
export declare const WORKER_APP_INSTANCE_TYPES: ReadonlySet<string>;
|
|
81
102
|
/** The most instances one container class may scale to. */
|
|
82
103
|
export declare const WORKER_APP_MAX_INSTANCES = 100;
|
|
83
|
-
export { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
|
|
104
|
+
export { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_CRONS_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_NO_DELETE_MAX, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
|
|
84
105
|
export declare const WORKER_APP_KEYS_URL_VAR = "KEYS_URL";
|
|
85
106
|
/** True when the entry needs the org's Postgres database: a Coolify app with `database`, or a Worker app with `workerApp.database`. */
|
|
86
107
|
export declare const entryHasDatabase: (entry: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { WORKER_APP_ENV_NAME as ENV_NAME, WORKER_APP_FUNCTION_SIGNATURE as FUNCTION_SIGNATURE, SECRET_LIKE_NAME as SECRET_NAME, WORKER_APP_TABLE_NAME as TABLE_NAME, WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
|
|
2
|
+
import { WORKER_APP_ENV_NAME as ENV_NAME, WORKER_APP_FUNCTION_SIGNATURE as FUNCTION_SIGNATURE, SECRET_LIKE_NAME as SECRET_NAME, WORKER_APP_TABLE_NAME as TABLE_NAME, WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_CRONS_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_NO_DELETE_MAX, WORKER_APP_RESERVED_NAMES, workerAppCronProblem, } from './entry-rules.js';
|
|
3
3
|
import { validateSlug } from './slug.js';
|
|
4
4
|
/** The compatibility flags a Worker app may ask for. */
|
|
5
5
|
export const WORKER_APP_COMPATIBILITY_FLAGS = new Set(['nodejs_compat']);
|
|
@@ -13,7 +13,7 @@ export const WORKER_APP_INSTANCE_TYPES = new Set([
|
|
|
13
13
|
]);
|
|
14
14
|
/** The most instances one container class may scale to. */
|
|
15
15
|
export const WORKER_APP_MAX_INSTANCES = 100;
|
|
16
|
-
export { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
|
|
16
|
+
export { WORKER_APP_APPEND_ONLY_MAX, WORKER_APP_CRONS_MAX, WORKER_APP_GRANTS_MAX, WORKER_APP_KEYS_SECRET, WORKER_APP_NO_DELETE_MAX, WORKER_APP_RESERVED_NAMES, } from './entry-rules.js';
|
|
17
17
|
export const WORKER_APP_KEYS_URL_VAR = 'KEYS_URL';
|
|
18
18
|
/** True when the entry needs the org's Postgres database: a Coolify app with `database`, or a Worker app with `workerApp.database`. */
|
|
19
19
|
export const entryHasDatabase = (entry) => entry.database || Boolean(entry.workerApp?.database);
|
|
@@ -52,6 +52,9 @@ const MIGRATION_TAG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
|
52
52
|
const IMAGE_REPOSITORY = /^[a-z0-9]+([._-][a-z0-9]+)*(:[0-9]+)?(\/[a-z0-9]+([._-][a-z0-9]+)*)+$/;
|
|
53
53
|
const COMPAT_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
54
54
|
const VAR_MAX_LENGTH = 1000;
|
|
55
|
+
/** `heldEnvRef` in `org.ts`: the ref is at most 128 characters and 52 of them are fixed. */
|
|
56
|
+
const HELD_REF_MAX = 128;
|
|
57
|
+
const HELD_REF_FIXED = 52;
|
|
55
58
|
/**
|
|
56
59
|
* Checks an artifact reference: an https URL with no userinfo, query or
|
|
57
60
|
* fragment (those end up verbatim in recorded scope ids) and a lowercase hex
|
|
@@ -193,9 +196,9 @@ export function validateEntryWorkerApp(entry, hosting, names) {
|
|
|
193
196
|
if (Object.keys(entry.env).length > 0) {
|
|
194
197
|
return `${at} has no Coolify app, so env does not apply: use workerApp.vars or workerApp.secrets`;
|
|
195
198
|
}
|
|
196
|
-
const unsupported = Object.keys(entry.uses ?? {}).find((use) => use !== 'auth' && use !== 'audit');
|
|
199
|
+
const unsupported = Object.keys(entry.uses ?? {}).find((use) => use !== 'auth' && use !== 'audit' && use !== 'analytics');
|
|
197
200
|
if (unsupported)
|
|
198
|
-
return `${at} supports uses.auth and uses.
|
|
201
|
+
return `${at} supports uses.auth, uses.audit and uses.analytics only, not uses.${unsupported}`;
|
|
199
202
|
if (entry.key.length > WORKER_NAME_MAX_LENGTH - WORKER_NAME_FIXED_LENGTH) {
|
|
200
203
|
return `${at} entry key is too long: the deployed Worker's name (an 8-character org hash, the key, and "${WORKER_APP_NAME_SUFFIX}") must fit Cloudflare's ${WORKER_NAME_MAX_LENGTH}-character limit`;
|
|
201
204
|
}
|
|
@@ -278,6 +281,16 @@ export function validateEntryWorkerApp(entry, hosting, names) {
|
|
|
278
281
|
return invalid;
|
|
279
282
|
if (source?.from === 'generated')
|
|
280
283
|
continue;
|
|
284
|
+
if (source?.from === 'heldSecret') {
|
|
285
|
+
if (name === WORKER_APP_KEYS_SECRET) {
|
|
286
|
+
return `${at} secret '${name}' is the custody credential: declare it as keysCustody, not heldSecret`;
|
|
287
|
+
}
|
|
288
|
+
// `heldEnvRef`'s own limit (org.ts restates the ref: this file cannot import it).
|
|
289
|
+
if (HELD_REF_FIXED + entry.key.length + name.length > HELD_REF_MAX) {
|
|
290
|
+
return `${at} secret '${name}' heldSecret: the name and the entry key are too long to hold a value under (at most ${HELD_REF_MAX - HELD_REF_FIXED} characters together)`;
|
|
291
|
+
}
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
281
294
|
if (source?.from !== 'keysCustody')
|
|
282
295
|
return `${at} secret '${name}' has an unknown source`;
|
|
283
296
|
keysSecrets++;
|
|
@@ -312,6 +325,9 @@ export function validateEntryWorkerApp(entry, hosting, names) {
|
|
|
312
325
|
// Not a URL: nothing to check.
|
|
313
326
|
}
|
|
314
327
|
}
|
|
328
|
+
const invalidTriggers = validateWorkerAppTriggers(at, app);
|
|
329
|
+
if (invalidTriggers)
|
|
330
|
+
return invalidTriggers;
|
|
315
331
|
const invalidDatabase = validateWorkerAppDatabase(at, app);
|
|
316
332
|
if (invalidDatabase)
|
|
317
333
|
return invalidDatabase;
|
|
@@ -332,11 +348,11 @@ function validateWorkerAppDatabase(at, app) {
|
|
|
332
348
|
if (database === undefined)
|
|
333
349
|
return null;
|
|
334
350
|
if (typeof database !== 'object' || database === null || Array.isArray(database)) {
|
|
335
|
-
return `${at} database must be an object: { appendOnly?: string[]; grants?: string[]; migrationsSha256?: string }`;
|
|
351
|
+
return `${at} database must be an object: { appendOnly?: string[]; noDelete?: string[]; grants?: string[]; migrationsSha256?: string }`;
|
|
336
352
|
}
|
|
337
|
-
const unknown = Object.keys(database).find((key) => key !== 'appendOnly' && key !== 'grants' && key !== 'migrationsSha256');
|
|
353
|
+
const unknown = Object.keys(database).find((key) => key !== 'appendOnly' && key !== 'noDelete' && key !== 'grants' && key !== 'migrationsSha256');
|
|
338
354
|
if (unknown) {
|
|
339
|
-
return `${at} database.${unknown} is not a setting: only appendOnly, grants and migrationsSha256`;
|
|
355
|
+
return `${at} database.${unknown} is not a setting: only appendOnly, noDelete, grants and migrationsSha256`;
|
|
340
356
|
}
|
|
341
357
|
const grants = database.grants;
|
|
342
358
|
if (grants !== undefined) {
|
|
@@ -357,21 +373,63 @@ function validateWorkerAppDatabase(at, app) {
|
|
|
357
373
|
if (sha !== undefined && (typeof sha !== 'string' || !SHA256_HEX.test(sha))) {
|
|
358
374
|
return `${at} database.migrationsSha256 must be 64 lowercase hex characters`;
|
|
359
375
|
}
|
|
360
|
-
const appendOnly = database
|
|
361
|
-
if (appendOnly ===
|
|
362
|
-
return
|
|
363
|
-
|
|
364
|
-
|
|
376
|
+
const appendOnly = tableList(at, 'appendOnly', WORKER_APP_APPEND_ONLY_MAX, database);
|
|
377
|
+
if (typeof appendOnly === 'string')
|
|
378
|
+
return appendOnly;
|
|
379
|
+
const noDelete = tableList(at, 'noDelete', WORKER_APP_NO_DELETE_MAX, database);
|
|
380
|
+
if (typeof noDelete === 'string')
|
|
381
|
+
return noDelete;
|
|
382
|
+
const both = noDelete.find((table) => appendOnly.includes(table));
|
|
383
|
+
if (both) {
|
|
384
|
+
return `${at} database.noDelete names '${both}', which database.appendOnly already covers (append-only is stricter): name it in one list`;
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
/** A table list (`appendOnly` or `noDelete`) of `database`: its names, or the refusal. */
|
|
389
|
+
function tableList(at, key, max, database) {
|
|
390
|
+
const list = database[key];
|
|
391
|
+
if (list === undefined)
|
|
392
|
+
return [];
|
|
393
|
+
if (!Array.isArray(list) || list.length > max) {
|
|
394
|
+
return `${at} database.${key} must be a list of at most ${max} table names`;
|
|
365
395
|
}
|
|
366
396
|
const seen = new Set();
|
|
367
|
-
for (const table of
|
|
397
|
+
for (const table of list) {
|
|
368
398
|
if (typeof table !== 'string' || !TABLE_NAME.test(table)) {
|
|
369
|
-
return `${at} database
|
|
399
|
+
return `${at} database.${key} names '${String(table).slice(0, 80)}', which is not a table name (letters, digits and '_', optionally schema.table)`;
|
|
370
400
|
}
|
|
371
401
|
if (seen.has(table))
|
|
372
|
-
return `${at} database
|
|
402
|
+
return `${at} database.${key} names '${table}' twice`;
|
|
373
403
|
seen.add(table);
|
|
374
404
|
}
|
|
405
|
+
return [...seen];
|
|
406
|
+
}
|
|
407
|
+
/** `triggers`: only `crons`, at most {@link WORKER_APP_CRONS_MAX}, each a standard five-field cron, each once. */
|
|
408
|
+
function validateWorkerAppTriggers(at, app) {
|
|
409
|
+
const triggers = app.triggers;
|
|
410
|
+
if (triggers === undefined)
|
|
411
|
+
return null;
|
|
412
|
+
if (typeof triggers !== 'object' || triggers === null || Array.isArray(triggers)) {
|
|
413
|
+
return `${at} triggers must be an object: { crons?: string[] }`;
|
|
414
|
+
}
|
|
415
|
+
const unknown = Object.keys(triggers).find((key) => key !== 'crons');
|
|
416
|
+
if (unknown)
|
|
417
|
+
return `${at} triggers.${unknown} is not a trigger: only crons`;
|
|
418
|
+
const crons = triggers.crons;
|
|
419
|
+
if (crons === undefined)
|
|
420
|
+
return null;
|
|
421
|
+
if (!Array.isArray(crons) || crons.length > WORKER_APP_CRONS_MAX) {
|
|
422
|
+
return `${at} triggers.crons must be a list of at most ${WORKER_APP_CRONS_MAX} cron expressions (Cloudflare's limit for one Worker)`;
|
|
423
|
+
}
|
|
424
|
+
const seen = new Set();
|
|
425
|
+
for (const cron of crons) {
|
|
426
|
+
const problem = workerAppCronProblem(cron);
|
|
427
|
+
if (problem)
|
|
428
|
+
return `${at} triggers.crons: ${problem}`;
|
|
429
|
+
if (seen.has(cron))
|
|
430
|
+
return `${at} triggers.crons names '${cron}' twice`;
|
|
431
|
+
seen.add(cron);
|
|
432
|
+
}
|
|
375
433
|
return null;
|
|
376
434
|
}
|
|
377
435
|
function validateDurableObjects(at, app, claim) {
|