@wtfalch/foundry 0.1.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/LICENSE +21 -0
- package/dist/apply.d.ts +27 -0
- package/dist/apply.js +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +7 -0
- package/dist/migrations/0001_declared_org.sql +61 -0
- package/dist/migrations/0002_applied_state.sql +43 -0
- package/dist/migrations/0003_foundry_runs.sql +53 -0
- package/dist/migrations/0004_slug_unique.sql +26 -0
- package/dist/migrations/0005_spend_limits.sql +34 -0
- package/dist/migrations/0006_buyer_identity.sql +31 -0
- package/dist/migrations/0007_site.sql +18 -0
- package/dist/org.d.ts +276 -0
- package/dist/org.js +29 -0
- package/dist/plan/diff.d.ts +52 -0
- package/dist/plan/diff.js +302 -0
- package/dist/plan/index.d.ts +8 -0
- package/dist/plan/index.js +4 -0
- package/dist/plan/live.d.ts +62 -0
- package/dist/plan/live.js +12 -0
- package/dist/plan/plan-apply.d.ts +107 -0
- package/dist/plan/plan-apply.js +172 -0
- package/dist/plan/providers-readers.d.ts +86 -0
- package/dist/plan/providers-readers.js +83 -0
- package/dist/plan.d.ts +50 -0
- package/dist/plan.js +1 -0
- package/dist/runner/db.d.ts +8 -0
- package/dist/runner/db.js +1 -0
- package/dist/runner/errors.d.ts +37 -0
- package/dist/runner/errors.js +57 -0
- package/dist/runner/index.d.ts +4 -0
- package/dist/runner/index.js +2 -0
- package/dist/runner/runner.d.ts +6 -0
- package/dist/runner/runner.js +137 -0
- package/dist/runner/schema.d.ts +163 -0
- package/dist/runner/schema.js +25 -0
- package/dist/runner/types.d.ts +97 -0
- package/dist/runner/types.js +1 -0
- package/dist/slug.d.ts +38 -0
- package/dist/slug.js +141 -0
- package/dist/state.d.ts +63 -0
- package/dist/state.js +1 -0
- package/dist/store/postgres.d.ts +43 -0
- package/dist/store/postgres.js +267 -0
- package/dist/store/schema.d.ts +615 -0
- package/dist/store/schema.js +59 -0
- package/dist/store.d.ts +23 -0
- package/dist/store.js +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { Checkpoint } from '../state.js';
|
|
2
|
+
import type { RunFailure, RunStatus } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Mirrors `migrations/0001_foundry_runs.sql`, which is the source of
|
|
5
|
+
* truth: the SQL carries the CHECKs and the partial unique index that is
|
|
6
|
+
* the run's lock, neither of which drizzle-kit would generate here (this
|
|
7
|
+
* repo hand-writes migrations, per app-template's convention). Internal to
|
|
8
|
+
* `./runner` -- a host reaches this table only through {@link createJobRunner},
|
|
9
|
+
* never by importing it directly (`@wtfalch/keys`' `./held` does the same
|
|
10
|
+
* with its own tables).
|
|
11
|
+
*/
|
|
12
|
+
export declare const foundryRuns: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
13
|
+
name: "foundry_runs";
|
|
14
|
+
schema: undefined;
|
|
15
|
+
columns: {
|
|
16
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
17
|
+
name: "id";
|
|
18
|
+
tableName: "foundry_runs";
|
|
19
|
+
dataType: "string";
|
|
20
|
+
columnType: "PgText";
|
|
21
|
+
data: string;
|
|
22
|
+
driverParam: string;
|
|
23
|
+
notNull: true;
|
|
24
|
+
hasDefault: false;
|
|
25
|
+
isPrimaryKey: true;
|
|
26
|
+
isAutoincrement: false;
|
|
27
|
+
hasRuntimeDefault: false;
|
|
28
|
+
enumValues: [string, ...string[]];
|
|
29
|
+
baseColumn: never;
|
|
30
|
+
identity: undefined;
|
|
31
|
+
generated: undefined;
|
|
32
|
+
}, {}, {}>;
|
|
33
|
+
orgId: import("drizzle-orm/pg-core").PgColumn<{
|
|
34
|
+
name: "org_id";
|
|
35
|
+
tableName: "foundry_runs";
|
|
36
|
+
dataType: "string";
|
|
37
|
+
columnType: "PgText";
|
|
38
|
+
data: string;
|
|
39
|
+
driverParam: string;
|
|
40
|
+
notNull: true;
|
|
41
|
+
hasDefault: false;
|
|
42
|
+
isPrimaryKey: false;
|
|
43
|
+
isAutoincrement: false;
|
|
44
|
+
hasRuntimeDefault: false;
|
|
45
|
+
enumValues: [string, ...string[]];
|
|
46
|
+
baseColumn: never;
|
|
47
|
+
identity: undefined;
|
|
48
|
+
generated: undefined;
|
|
49
|
+
}, {}, {}>;
|
|
50
|
+
status: import("drizzle-orm/pg-core").PgColumn<{
|
|
51
|
+
name: "status";
|
|
52
|
+
tableName: "foundry_runs";
|
|
53
|
+
dataType: "string";
|
|
54
|
+
columnType: "PgText";
|
|
55
|
+
data: RunStatus;
|
|
56
|
+
driverParam: string;
|
|
57
|
+
notNull: true;
|
|
58
|
+
hasDefault: true;
|
|
59
|
+
isPrimaryKey: false;
|
|
60
|
+
isAutoincrement: false;
|
|
61
|
+
hasRuntimeDefault: false;
|
|
62
|
+
enumValues: [string, ...string[]];
|
|
63
|
+
baseColumn: never;
|
|
64
|
+
identity: undefined;
|
|
65
|
+
generated: undefined;
|
|
66
|
+
}, {}, {
|
|
67
|
+
$type: RunStatus;
|
|
68
|
+
}>;
|
|
69
|
+
stepIds: import("drizzle-orm/pg-core").PgColumn<{
|
|
70
|
+
name: "step_ids";
|
|
71
|
+
tableName: "foundry_runs";
|
|
72
|
+
dataType: "json";
|
|
73
|
+
columnType: "PgJsonb";
|
|
74
|
+
data: string[];
|
|
75
|
+
driverParam: unknown;
|
|
76
|
+
notNull: true;
|
|
77
|
+
hasDefault: false;
|
|
78
|
+
isPrimaryKey: false;
|
|
79
|
+
isAutoincrement: false;
|
|
80
|
+
hasRuntimeDefault: false;
|
|
81
|
+
enumValues: undefined;
|
|
82
|
+
baseColumn: never;
|
|
83
|
+
identity: undefined;
|
|
84
|
+
generated: undefined;
|
|
85
|
+
}, {}, {
|
|
86
|
+
$type: string[];
|
|
87
|
+
}>;
|
|
88
|
+
checkpoints: import("drizzle-orm/pg-core").PgColumn<{
|
|
89
|
+
name: "checkpoints";
|
|
90
|
+
tableName: "foundry_runs";
|
|
91
|
+
dataType: "json";
|
|
92
|
+
columnType: "PgJsonb";
|
|
93
|
+
data: Checkpoint[];
|
|
94
|
+
driverParam: unknown;
|
|
95
|
+
notNull: true;
|
|
96
|
+
hasDefault: true;
|
|
97
|
+
isPrimaryKey: false;
|
|
98
|
+
isAutoincrement: false;
|
|
99
|
+
hasRuntimeDefault: false;
|
|
100
|
+
enumValues: undefined;
|
|
101
|
+
baseColumn: never;
|
|
102
|
+
identity: undefined;
|
|
103
|
+
generated: undefined;
|
|
104
|
+
}, {}, {
|
|
105
|
+
$type: Checkpoint[];
|
|
106
|
+
}>;
|
|
107
|
+
failure: import("drizzle-orm/pg-core").PgColumn<{
|
|
108
|
+
name: "failure";
|
|
109
|
+
tableName: "foundry_runs";
|
|
110
|
+
dataType: "json";
|
|
111
|
+
columnType: "PgJsonb";
|
|
112
|
+
data: RunFailure;
|
|
113
|
+
driverParam: unknown;
|
|
114
|
+
notNull: false;
|
|
115
|
+
hasDefault: false;
|
|
116
|
+
isPrimaryKey: false;
|
|
117
|
+
isAutoincrement: false;
|
|
118
|
+
hasRuntimeDefault: false;
|
|
119
|
+
enumValues: undefined;
|
|
120
|
+
baseColumn: never;
|
|
121
|
+
identity: undefined;
|
|
122
|
+
generated: undefined;
|
|
123
|
+
}, {}, {
|
|
124
|
+
$type: RunFailure;
|
|
125
|
+
}>;
|
|
126
|
+
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
127
|
+
name: "created_at";
|
|
128
|
+
tableName: "foundry_runs";
|
|
129
|
+
dataType: "date";
|
|
130
|
+
columnType: "PgTimestamp";
|
|
131
|
+
data: Date;
|
|
132
|
+
driverParam: string;
|
|
133
|
+
notNull: true;
|
|
134
|
+
hasDefault: true;
|
|
135
|
+
isPrimaryKey: false;
|
|
136
|
+
isAutoincrement: false;
|
|
137
|
+
hasRuntimeDefault: false;
|
|
138
|
+
enumValues: undefined;
|
|
139
|
+
baseColumn: never;
|
|
140
|
+
identity: undefined;
|
|
141
|
+
generated: undefined;
|
|
142
|
+
}, {}, {}>;
|
|
143
|
+
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
144
|
+
name: "updated_at";
|
|
145
|
+
tableName: "foundry_runs";
|
|
146
|
+
dataType: "date";
|
|
147
|
+
columnType: "PgTimestamp";
|
|
148
|
+
data: Date;
|
|
149
|
+
driverParam: string;
|
|
150
|
+
notNull: true;
|
|
151
|
+
hasDefault: true;
|
|
152
|
+
isPrimaryKey: false;
|
|
153
|
+
isAutoincrement: false;
|
|
154
|
+
hasRuntimeDefault: false;
|
|
155
|
+
enumValues: undefined;
|
|
156
|
+
baseColumn: never;
|
|
157
|
+
identity: undefined;
|
|
158
|
+
generated: undefined;
|
|
159
|
+
}, {}, {}>;
|
|
160
|
+
};
|
|
161
|
+
dialect: "pg";
|
|
162
|
+
}>;
|
|
163
|
+
export type FoundryRunRow = typeof foundryRuns.$inferSelect;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
|
3
|
+
/**
|
|
4
|
+
* Mirrors `migrations/0001_foundry_runs.sql`, which is the source of
|
|
5
|
+
* truth: the SQL carries the CHECKs and the partial unique index that is
|
|
6
|
+
* the run's lock, neither of which drizzle-kit would generate here (this
|
|
7
|
+
* repo hand-writes migrations, per app-template's convention). Internal to
|
|
8
|
+
* `./runner` -- a host reaches this table only through {@link createJobRunner},
|
|
9
|
+
* never by importing it directly (`@wtfalch/keys`' `./held` does the same
|
|
10
|
+
* with its own tables).
|
|
11
|
+
*/
|
|
12
|
+
export const foundryRuns = pgTable('foundry_runs', {
|
|
13
|
+
id: text('id').primaryKey(),
|
|
14
|
+
orgId: text('org_id').notNull(),
|
|
15
|
+
status: text('status').notNull().default('running').$type(),
|
|
16
|
+
stepIds: jsonb('step_ids').notNull().$type(),
|
|
17
|
+
checkpoints: jsonb('checkpoints').notNull().default([]).$type(),
|
|
18
|
+
failure: jsonb('failure').$type(),
|
|
19
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
20
|
+
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
21
|
+
}, (table) => [
|
|
22
|
+
uniqueIndex('foundry_runs_org_lock_idx')
|
|
23
|
+
.on(table.orgId)
|
|
24
|
+
.where(sql `${table.status} <> 'succeeded'`),
|
|
25
|
+
]);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { OrgId } from '../org.js';
|
|
2
|
+
import type { Checkpoint } from '../state.js';
|
|
3
|
+
import type { RunnerDb } from './db.js';
|
|
4
|
+
/**
|
|
5
|
+
* One step a run executes. `id` is the checkpoint key (#17): stable across
|
|
6
|
+
* a kill and restart, since a run persists only `stepIds`, never a
|
|
7
|
+
* function -- the caller (the worker process) supplies the same
|
|
8
|
+
* `RunnerStep[]` again on every call to {@link JobRunner.runSteps}, and its
|
|
9
|
+
* ids must match the run's own `stepIds` exactly, in order.
|
|
10
|
+
*
|
|
11
|
+
* What `run()` actually does against Coolify, Cloudflare or Zitadel is
|
|
12
|
+
* someone else's ticket (#17's boundary drawn against #19/#20) -- this
|
|
13
|
+
* runner only sequences, checkpoints and locks.
|
|
14
|
+
*/
|
|
15
|
+
export interface RunnerStep {
|
|
16
|
+
id: string;
|
|
17
|
+
run(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export type RunStatus = 'running' | 'succeeded' | 'failed';
|
|
20
|
+
/** Which step stopped the run, and why. Never cleared automatically -- #9: "no automatic retry". */
|
|
21
|
+
export interface RunFailure {
|
|
22
|
+
step: string;
|
|
23
|
+
message: string;
|
|
24
|
+
at: string;
|
|
25
|
+
}
|
|
26
|
+
/** A run row, read back for polling (#17: "progress is readable by polling"). */
|
|
27
|
+
export interface RunRecord {
|
|
28
|
+
id: string;
|
|
29
|
+
orgId: OrgId;
|
|
30
|
+
status: RunStatus;
|
|
31
|
+
/** The run's fixed plan, in order -- what {@link JobRunner.runSteps} checks a resume's steps against. */
|
|
32
|
+
stepIds: string[];
|
|
33
|
+
checkpoints: Checkpoint[];
|
|
34
|
+
failure: RunFailure | null;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
}
|
|
38
|
+
export interface RunStepsOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Checked before each step that has not already checkpointed; set, the
|
|
41
|
+
* run stops in place -- its row keeps whatever status and checkpoints it
|
|
42
|
+
* already had, neither 'succeeded' nor 'failed'. This is how a test
|
|
43
|
+
* lands #17's "killing the worker mid-run" at a deterministic point: in
|
|
44
|
+
* production nothing signals this, the process simply stops, and the
|
|
45
|
+
* row left behind is the same either way. The next call to `runSteps`
|
|
46
|
+
* with this `runId`, from the steps already done, is the resume -- there
|
|
47
|
+
* is no separate "resume" entry point because none is needed.
|
|
48
|
+
*/
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
}
|
|
51
|
+
export interface CreateJobRunnerOptions {
|
|
52
|
+
db: RunnerDb;
|
|
53
|
+
/** Overridable clock, for deterministic tests (mirrors `@wtfalch/keys`' `held` module's own `now`). */
|
|
54
|
+
now?: () => Date;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The job runner (#17): enqueue an apply from a call without running it,
|
|
58
|
+
* run its steps from a worker process, read progress by polling. `db` is
|
|
59
|
+
* fixed at construction (mirrors `@wtfalch/keys`' `createHeldKeys`); every
|
|
60
|
+
* method after that takes only what varies per call.
|
|
61
|
+
*/
|
|
62
|
+
export interface JobRunner {
|
|
63
|
+
/**
|
|
64
|
+
* Writes the run's row and returns -- no step's `run()` is called here.
|
|
65
|
+
* That is the whole of "not in the request" (#17): the actual work
|
|
66
|
+
* happens only when a worker process later calls {@link runSteps} with
|
|
67
|
+
* this `runId`.
|
|
68
|
+
*
|
|
69
|
+
* Refused, per #9, when the org already has a run that has not
|
|
70
|
+
* succeeded: enforced by `foundry_runs_org_lock_idx`, a partial unique
|
|
71
|
+
* index, the lock as a database row rather than a lockfile, so the
|
|
72
|
+
* refusal holds across containers.
|
|
73
|
+
*/
|
|
74
|
+
enqueueApply(orgId: OrgId, steps: RunnerStep[]): Promise<{
|
|
75
|
+
runId: string;
|
|
76
|
+
}>;
|
|
77
|
+
/**
|
|
78
|
+
* Runs a run's remaining steps in order, checkpointing each on success.
|
|
79
|
+
* Steps already checkpointed are skipped -- the resume behaviour #17
|
|
80
|
+
* requires, driven entirely by what `foundry_runs` already holds, never
|
|
81
|
+
* by anything this process remembers.
|
|
82
|
+
*
|
|
83
|
+
* On a step's `run()` throwing, the row is marked `failed` with which
|
|
84
|
+
* step and why, and the lock stays held: the row does not become
|
|
85
|
+
* 'succeeded', so `foundry_runs_org_lock_idx` still blocks a new apply
|
|
86
|
+
* for this org (#9: "no automatic retry", "keeps the lock"). This
|
|
87
|
+
* method never retries; a human decides when to call it again, and
|
|
88
|
+
* doing so is what resumes a failed run from its checkpoint (#9's
|
|
89
|
+
* decision comment: "a human fixes the cause and resumes from that
|
|
90
|
+
* checkpoint").
|
|
91
|
+
*/
|
|
92
|
+
runSteps(runId: string, steps: RunnerStep[], options?: RunStepsOptions): Promise<RunRecord>;
|
|
93
|
+
/** A single read, for polling. Holds no connection open past the query. */
|
|
94
|
+
getRun(runId: string): Promise<RunRecord | null>;
|
|
95
|
+
/** The org's current lock-holding run, if it has one (status not yet 'succeeded'). */
|
|
96
|
+
currentRun(orgId: OrgId): Promise<RunRecord | null>;
|
|
97
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/slug.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declared org's slug: a pure validator, no I/O, no store access.
|
|
3
|
+
*
|
|
4
|
+
* Decided in https://github.com/wtfalch/foundry/issues/28, forced by the
|
|
5
|
+
* level-1 apex `oikos.host` being live: Cloudflare's free-tier Universal SSL
|
|
6
|
+
* covers a single wildcard level, so every level-1 org's hostnames are flat
|
|
7
|
+
* (`acme.oikos.host`, `acme-stage.oikos.host`, `acme-pr-12.oikos.host`).
|
|
8
|
+
* That makes the slug a live hostname from the moment the org exists — #7
|
|
9
|
+
* only froze it once the org had mail, which is too late: by then the
|
|
10
|
+
* hostname is already serving. This validator runs at creation, not at
|
|
11
|
+
* first use (#28 "Done when").
|
|
12
|
+
*
|
|
13
|
+
* Kept separate from `org.ts` and `store/postgres.ts` so it can be called
|
|
14
|
+
* from both without either depending on the other: `store/postgres.ts`
|
|
15
|
+
* calls it on `create` and `update` (#28), and anything that needs to
|
|
16
|
+
* pre-validate a slug before it ever reaches the store — the operator page,
|
|
17
|
+
* one day — can import it too.
|
|
18
|
+
*/
|
|
19
|
+
/** The flattened set {@link validateSlug} checks a candidate slug against. */
|
|
20
|
+
export declare const RESERVED_SLUGS: ReadonlySet<string>;
|
|
21
|
+
/**
|
|
22
|
+
* Why {@link validateSlug} refused a candidate slug, by name so a caller —
|
|
23
|
+
* `store/postgres.ts`'s `DeclaredOrgError`, eventually the operator page —
|
|
24
|
+
* can report it without parsing `message` text.
|
|
25
|
+
*/
|
|
26
|
+
export type SlugRejectionReason = 'invalid-label' | 'reserved' | 'environment-suffix';
|
|
27
|
+
export interface SlugRejection {
|
|
28
|
+
reason: SlugRejectionReason;
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Validates a candidate org slug against every rule #28 decided. Returns
|
|
33
|
+
* `null` when the slug is acceptable, or a {@link SlugRejection} naming
|
|
34
|
+
* exactly one reason otherwise — the first rule the slug fails, checked in
|
|
35
|
+
* the order below, since a slug that fails more than one rule still only
|
|
36
|
+
* needs one reason reported.
|
|
37
|
+
*/
|
|
38
|
+
export declare function validateSlug(slug: string): SlugRejection | null;
|
package/dist/slug.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The declared org's slug: a pure validator, no I/O, no store access.
|
|
3
|
+
*
|
|
4
|
+
* Decided in https://github.com/wtfalch/foundry/issues/28, forced by the
|
|
5
|
+
* level-1 apex `oikos.host` being live: Cloudflare's free-tier Universal SSL
|
|
6
|
+
* covers a single wildcard level, so every level-1 org's hostnames are flat
|
|
7
|
+
* (`acme.oikos.host`, `acme-stage.oikos.host`, `acme-pr-12.oikos.host`).
|
|
8
|
+
* That makes the slug a live hostname from the moment the org exists — #7
|
|
9
|
+
* only froze it once the org had mail, which is too late: by then the
|
|
10
|
+
* hostname is already serving. This validator runs at creation, not at
|
|
11
|
+
* first use (#28 "Done when").
|
|
12
|
+
*
|
|
13
|
+
* Kept separate from `org.ts` and `store/postgres.ts` so it can be called
|
|
14
|
+
* from both without either depending on the other: `store/postgres.ts`
|
|
15
|
+
* calls it on `create` and `update` (#28), and anything that needs to
|
|
16
|
+
* pre-validate a slug before it ever reaches the store — the operator page,
|
|
17
|
+
* one day — can import it too.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* #28's reserved list, verbatim, in its three named groups. Kept as three
|
|
21
|
+
* separate arrays rather than flattened so each group's citation stays next
|
|
22
|
+
* to the names it covers; {@link RESERVED_SLUGS} is the flattened set
|
|
23
|
+
* `validateSlug` actually checks against.
|
|
24
|
+
*/
|
|
25
|
+
const RESERVED_ESTATE_SERVICES = [
|
|
26
|
+
'archon',
|
|
27
|
+
'foundry',
|
|
28
|
+
'agora',
|
|
29
|
+
'files',
|
|
30
|
+
'ai',
|
|
31
|
+
'keys',
|
|
32
|
+
'chat',
|
|
33
|
+
'calls',
|
|
34
|
+
'calendar',
|
|
35
|
+
'threads',
|
|
36
|
+
'audit',
|
|
37
|
+
'authz',
|
|
38
|
+
'manage',
|
|
39
|
+
'operator',
|
|
40
|
+
'mailbox',
|
|
41
|
+
'oikos',
|
|
42
|
+
'cms',
|
|
43
|
+
];
|
|
44
|
+
const RESERVED_INFRASTRUCTURE_AND_MAIL = [
|
|
45
|
+
'www',
|
|
46
|
+
'api',
|
|
47
|
+
'cdn',
|
|
48
|
+
'static',
|
|
49
|
+
'assets',
|
|
50
|
+
'ns',
|
|
51
|
+
'ns1',
|
|
52
|
+
'ns2',
|
|
53
|
+
'mx',
|
|
54
|
+
'mail',
|
|
55
|
+
'smtp',
|
|
56
|
+
'imap',
|
|
57
|
+
'pop',
|
|
58
|
+
'webmail',
|
|
59
|
+
'autodiscover',
|
|
60
|
+
'autoconfig',
|
|
61
|
+
'ftp',
|
|
62
|
+
];
|
|
63
|
+
/**
|
|
64
|
+
* Auth-sensitive names and environment words: reserved because a customer
|
|
65
|
+
* holding one could phish the rest (#28).
|
|
66
|
+
*/
|
|
67
|
+
const RESERVED_AUTH_AND_ENVIRONMENTS = [
|
|
68
|
+
'auth',
|
|
69
|
+
'login',
|
|
70
|
+
'signin',
|
|
71
|
+
'sso',
|
|
72
|
+
'id',
|
|
73
|
+
'account',
|
|
74
|
+
'admin',
|
|
75
|
+
'console',
|
|
76
|
+
'dashboard',
|
|
77
|
+
'billing',
|
|
78
|
+
'payments',
|
|
79
|
+
'secure',
|
|
80
|
+
'status',
|
|
81
|
+
'docs',
|
|
82
|
+
'help',
|
|
83
|
+
'support',
|
|
84
|
+
'stage',
|
|
85
|
+
'staging',
|
|
86
|
+
'preview',
|
|
87
|
+
'pr',
|
|
88
|
+
'test',
|
|
89
|
+
'dev',
|
|
90
|
+
'demo',
|
|
91
|
+
'internal',
|
|
92
|
+
'private',
|
|
93
|
+
'system',
|
|
94
|
+
'root',
|
|
95
|
+
];
|
|
96
|
+
/** The flattened set {@link validateSlug} checks a candidate slug against. */
|
|
97
|
+
export const RESERVED_SLUGS = new Set([
|
|
98
|
+
...RESERVED_ESTATE_SERVICES,
|
|
99
|
+
...RESERVED_INFRASTRUCTURE_AND_MAIL,
|
|
100
|
+
...RESERVED_AUTH_AND_ENVIRONMENTS,
|
|
101
|
+
]);
|
|
102
|
+
/**
|
|
103
|
+
* A DNS label: 1-63 characters, lowercase `a-z`, `0-9` and hyphen, no
|
|
104
|
+
* leading or trailing hyphen. Consecutive hyphens are legal DNS (RFC 1035
|
|
105
|
+
* doesn't forbid them; only positions 3-4 being `--` matters, and that's an
|
|
106
|
+
* IDNA/punycode rule this estate has no reason to import) so this pattern
|
|
107
|
+
* deliberately does not add a rule #28 never asked for.
|
|
108
|
+
*/
|
|
109
|
+
const DNS_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
110
|
+
/** `<something>-pr-<digits>` — the preview-hostname shape (#28). */
|
|
111
|
+
const PR_SUFFIX = /-pr-\d+$/;
|
|
112
|
+
/** `<something>-stage` — the stage-hostname shape (#28). */
|
|
113
|
+
const STAGE_SUFFIX = /-stage$/;
|
|
114
|
+
/**
|
|
115
|
+
* Validates a candidate org slug against every rule #28 decided. Returns
|
|
116
|
+
* `null` when the slug is acceptable, or a {@link SlugRejection} naming
|
|
117
|
+
* exactly one reason otherwise — the first rule the slug fails, checked in
|
|
118
|
+
* the order below, since a slug that fails more than one rule still only
|
|
119
|
+
* needs one reason reported.
|
|
120
|
+
*/
|
|
121
|
+
export function validateSlug(slug) {
|
|
122
|
+
if (!DNS_LABEL.test(slug)) {
|
|
123
|
+
return {
|
|
124
|
+
reason: 'invalid-label',
|
|
125
|
+
message: `slug '${slug}' is not a valid DNS label: must be 1-63 characters, lowercase a-z, 0-9 and hyphen, and may not start or end with a hyphen`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (RESERVED_SLUGS.has(slug)) {
|
|
129
|
+
return {
|
|
130
|
+
reason: 'reserved',
|
|
131
|
+
message: `slug '${slug}' is reserved by the estate (#28) and cannot be taken by an org`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (STAGE_SUFFIX.test(slug) || PR_SUFFIX.test(slug)) {
|
|
135
|
+
return {
|
|
136
|
+
reason: 'environment-suffix',
|
|
137
|
+
message: `slug '${slug}' ends with an environment suffix ('-stage' or '-pr-<digits>') reserved for another org's own hostname (#28) — 'acme-stage' is only legal as acme's stage hostname, never as an org slug in its own right`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { OrgId } from './org.js';
|
|
2
|
+
/**
|
|
3
|
+
* One completed step of an in-progress or interrupted apply.
|
|
4
|
+
*
|
|
5
|
+
* Decided in #7 ("Shape") and https://github.com/wtfalch/foundry/issues/14's
|
|
6
|
+
* resolved research #5: a check/apply with lock and checkpoints, in the
|
|
7
|
+
* shape research #5 found already built in auth's namespace provisioner and
|
|
8
|
+
* reused rather than reinvented. A failed apply stops at its checkpoint and
|
|
9
|
+
* holds its lock as a database row, never a lockfile (#9, resolved).
|
|
10
|
+
*/
|
|
11
|
+
export interface Checkpoint {
|
|
12
|
+
step: string;
|
|
13
|
+
completedAt: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* When an apply last completed, and against which declared org version.
|
|
17
|
+
*
|
|
18
|
+
* Decided in #9 (resolved): "an apply is refused when its plan no longer
|
|
19
|
+
* matches reality" — `declaredOrgVersion` is what a later plan compares
|
|
20
|
+
* against to detect that drift.
|
|
21
|
+
*/
|
|
22
|
+
export interface LastApplied {
|
|
23
|
+
at: string;
|
|
24
|
+
declaredOrgVersion: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Foundry's own record of what has actually been made true for an org,
|
|
28
|
+
* separate from the human-edited {@link DeclaredOrg} it is checked against.
|
|
29
|
+
*
|
|
30
|
+
* Decided in #7 ("Shape") and #10 (resolved): both files live in foundry's
|
|
31
|
+
* database, keyed by the same {@link OrgId}; the declared file is what a
|
|
32
|
+
* human wants, this one is what foundry has done about it.
|
|
33
|
+
*/
|
|
34
|
+
export interface AppliedState {
|
|
35
|
+
orgId: OrgId;
|
|
36
|
+
/**
|
|
37
|
+
* One id per service that takes jobs from many orgs (Files, AI,
|
|
38
|
+
* integrations — https://github.com/wtfalch/foundry/issues/14's resolved
|
|
39
|
+
* research #4) plus the per-org services (chat, mail), each keyed by that
|
|
40
|
+
* service's own name for the org.
|
|
41
|
+
*/
|
|
42
|
+
serviceIds: Record<string, string>;
|
|
43
|
+
/** Checkpoints from the current or most recent apply. */
|
|
44
|
+
checkpoints: Checkpoint[];
|
|
45
|
+
/**
|
|
46
|
+
* The name of every {@link DeclaredOrg} field the most recent apply could
|
|
47
|
+
* not make true, because nothing implements it yet.
|
|
48
|
+
*
|
|
49
|
+
* Decision (#18): this field did not exist on the type this ticket found.
|
|
50
|
+
* #7 ("Shape") and #18's "Done when" both require `apply` to "fail loudly
|
|
51
|
+
* and by name on any field it cannot yet make true", but `Checkpoint` only
|
|
52
|
+
* records a *completed* step — recording an unimplemented field there
|
|
53
|
+
* would misuse its own documented meaning. #18's storage task ("`apply`
|
|
54
|
+
* must be able to fail by name on a field nothing implements yet, so the
|
|
55
|
+
* store records the field, not a rejection") needs somewhere to put that
|
|
56
|
+
* name, so this field carries it. Whether `apply` throws, partially
|
|
57
|
+
* applies, or does both is #19's decision, not this one; the store only
|
|
58
|
+
* has to persist whatever list it is given.
|
|
59
|
+
*/
|
|
60
|
+
failedFields: string[];
|
|
61
|
+
/** `null` before this org has ever had a completed apply. */
|
|
62
|
+
lastApplied: LastApplied | null;
|
|
63
|
+
}
|
package/dist/state.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
+
import type { DeclaredOrgStore } from '../store.js';
|
|
3
|
+
/**
|
|
4
|
+
* The host's own drizzle handle or a transaction open on it, same shape
|
|
5
|
+
* `@wtfalch/keys`'s `HeldKeysDb` uses: postgres-js in a real host, PGlite in
|
|
6
|
+
* this package's own tests (`./test/db.ts`). This module never opens a
|
|
7
|
+
* connection itself.
|
|
8
|
+
*/
|
|
9
|
+
export type FoundryStoreDb = PgDatabase<PgQueryResultHKT, any, any>;
|
|
10
|
+
/**
|
|
11
|
+
* Thrown by `create` and `update` when the store refuses a write: an id
|
|
12
|
+
* that already exists, an id that does not, a slug that fails
|
|
13
|
+
* `../slug.ts`'s `validateSlug` (#28: invalid DNS label, reserved, or an
|
|
14
|
+
* environment-suffix shape), a slug already taken by another org (#28:
|
|
15
|
+
* checked by the unique index `0004_slug_unique.sql` adds, whether caught
|
|
16
|
+
* ahead of time by a pre-check or by a race this store maps back to the
|
|
17
|
+
* same error) or — the one impossible combination #18 implements — a slug
|
|
18
|
+
* change once the org has mail. `store.ts`'s `update` doc requires a caller
|
|
19
|
+
* to be "ready for this to throw rather than silently drop a field"; this
|
|
20
|
+
* is that throw.
|
|
21
|
+
*/
|
|
22
|
+
export declare class DeclaredOrgError extends Error {
|
|
23
|
+
constructor(message: string);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* `DeclaredOrgStore` (`../store.ts`) on Postgres, for #18: create, read and
|
|
27
|
+
* edit a {@link DeclaredOrg}, and read and write the {@link AppliedState}
|
|
28
|
+
* next to it, both in foundry's own database per #10 (resolved) — never git.
|
|
29
|
+
*
|
|
30
|
+
* Validation here is deliberately narrow. #18's "Done when" list names one
|
|
31
|
+
* impossible combination in full — the slug freezes once the org has mail
|
|
32
|
+
* (#7 corrections 3) — and #28 adds one more, in full: the slug is a
|
|
33
|
+
* validated (`../slug.ts`), globally unique, non-reserved DNS label, checked
|
|
34
|
+
* on both `create` and `update`. #27 adds a third, narrower rule: storage and
|
|
35
|
+
* AI spend limits are bigint-safe decimal strings of non-negative integers
|
|
36
|
+
* (or `null` for uncapped), checked the same way on both `create` and the
|
|
37
|
+
* merged result of `update`. The broader "combinations foundry knows are
|
|
38
|
+
* impossible are rejected with a reason" line needs a ruleset (domain
|
|
39
|
+
* requirements per service, `adoptedApp.form`, mail-level transitions…)
|
|
40
|
+
* that later tickets, not #18, #27 or #28, decide; `update` still merges and
|
|
41
|
+
* stores any other patch as given.
|
|
42
|
+
*/
|
|
43
|
+
export declare function createPostgresDeclaredOrgStore(db: FoundryStoreDb): DeclaredOrgStore;
|