@rebasepro/types 0.16.0 → 0.16.1-canary.g0d7af95
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/call_context.d.ts +5 -5
- package/dist/controllers/auth_state.d.ts +1 -1
- package/dist/controllers/client.d.ts +22 -9
- package/dist/controllers/collection_registry.d.ts +2 -2
- package/dist/controllers/data.d.ts +4 -12
- package/dist/controllers/data_driver.d.ts +7 -7
- package/dist/controllers/email.d.ts +54 -2
- package/dist/controllers/index.d.ts +8 -9
- package/dist/controllers/storage.d.ts +4 -4
- package/dist/index.d.ts +5 -5
- package/dist/index.es.js +433 -3
- package/dist/index.es.js.map +1 -1
- package/dist/types/admin_block.d.ts +1 -1
- package/dist/types/auth_adapter.d.ts +18 -9
- package/dist/types/backend.d.ts +23 -10
- package/dist/types/collection_contract.d.ts +1 -1
- package/dist/types/collections.d.ts +34 -9
- package/dist/types/component_ref.d.ts +3 -2
- package/dist/types/cron.d.ts +1 -25
- package/dist/types/data_source.d.ts +1 -1
- package/dist/types/database_adapter.d.ts +5 -5
- package/dist/types/entities.d.ts +1 -1
- package/dist/types/entity_callbacks.d.ts +4 -4
- package/dist/types/index.d.ts +33 -29
- package/dist/types/indexes.d.ts +179 -0
- package/dist/types/project_manifest.d.ts +132 -27
- package/dist/types/properties.d.ts +58 -6
- package/dist/types/relations.d.ts +1 -1
- package/dist/types/resource_kinds.d.ts +189 -0
- package/dist/types/resources.d.ts +197 -0
- package/dist/types/schema_editing.d.ts +127 -0
- package/dist/types/schema_version.d.ts +1 -1
- package/dist/types/security_rules.d.ts +1 -1
- package/dist/types/storage_source.d.ts +27 -0
- package/dist/users/index.d.ts +1 -1
- package/package.json +2 -2
- package/src/controllers/client.ts +14 -1
- package/src/controllers/data.ts +0 -9
- package/src/controllers/email.ts +55 -2
- package/src/controllers/index.ts +0 -1
- package/src/controllers/storage.ts +4 -4
- package/src/types/admin_block.ts +1 -2
- package/src/types/auth_adapter.ts +18 -10
- package/src/types/backend.ts +18 -1
- package/src/types/collections.ts +28 -2
- package/src/types/component_ref.ts +3 -2
- package/src/types/cron.ts +0 -24
- package/src/types/index.ts +4 -0
- package/src/types/indexes.ts +180 -0
- package/src/types/project_manifest.ts +139 -26
- package/src/types/properties.ts +54 -0
- package/src/types/resource_kinds.ts +324 -0
- package/src/types/resources.ts +368 -0
- package/src/types/schema_editing.ts +154 -0
- package/src/types/storage_source.ts +28 -0
- package/dist/controllers/database_admin.d.ts +0 -11
- package/src/controllers/database_admin.ts +0 -22
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kinds Rebase ships, and the constructors a project declares them with.
|
|
3
|
+
*
|
|
4
|
+
* Each kind is registered rather than hardcoded, so a fourth one arrives
|
|
5
|
+
* without editing a manifest schema, a validator and a switch statement. That
|
|
6
|
+
* cost is precisely why databases and buckets ended up declared in different
|
|
7
|
+
* files with different rules — the cheapest thing to do was always to bolt the
|
|
8
|
+
* new kind onto whichever home was nearest.
|
|
9
|
+
*
|
|
10
|
+
* A kind owns its engine list. `custom:<id>` is always accepted, so a build
|
|
11
|
+
* that ships an engine this package has never heard of says so at the call site
|
|
12
|
+
* instead of looking like a typo of one that exists.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_RESOURCE_KEY,
|
|
16
|
+
declareResource,
|
|
17
|
+
declaredResources,
|
|
18
|
+
registerResourceKind,
|
|
19
|
+
type DeclareOptions,
|
|
20
|
+
type ResourceHandle,
|
|
21
|
+
type ResourceTransport
|
|
22
|
+
} from "./resources";
|
|
23
|
+
|
|
24
|
+
// ── database ─────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
registerResourceKind({
|
|
27
|
+
kind: "database",
|
|
28
|
+
engines: ["postgres", "mongodb", "firestore", "sqlite"],
|
|
29
|
+
defaultEngine: "postgres",
|
|
30
|
+
// REBASE_DRIVER overrides the engine's default driver package; the pool
|
|
31
|
+
// ceiling is per-source because one source can be a single-session PGlite
|
|
32
|
+
// and another a real server.
|
|
33
|
+
envBases: ["DATABASE_URL", "REBASE_DRIVER", "REBASE_DB_POOL_MAX"],
|
|
34
|
+
// No per-engine narrowing: every engine binds from the same three, and the
|
|
35
|
+
// driver package that differs between them is named by REBASE_DRIVER either
|
|
36
|
+
// way.
|
|
37
|
+
optionKeys: ["databaseId", "migrations"],
|
|
38
|
+
// A backend without a database is not a backend, so one exists whether or
|
|
39
|
+
// not a project says so.
|
|
40
|
+
implicitDefault: true
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Options a database accepts beyond the common ones. */
|
|
44
|
+
export interface DatabaseOptions extends DeclareOptions {
|
|
45
|
+
/**
|
|
46
|
+
* The physical database or schema within the engine, when it differs from
|
|
47
|
+
* the engine's own default. Threaded to drivers as `databaseId`.
|
|
48
|
+
*/
|
|
49
|
+
databaseId?: string;
|
|
50
|
+
/** Directory of migration files, relative to the config directory. */
|
|
51
|
+
migrations?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A database handle. Collections point at it via `dataSource`. */
|
|
55
|
+
export type DatabaseHandle = ResourceHandle;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Declare a database.
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* export const main = database(); // the default one
|
|
62
|
+
* export const analytics = database("analytics"); // reads DATABASE_URL__ANALYTICS
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function database(key: string = DEFAULT_RESOURCE_KEY, options: DatabaseOptions = {}): DatabaseHandle {
|
|
66
|
+
return declareResource("database", key, options);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── bucket ───────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
registerResourceKind({
|
|
72
|
+
kind: "bucket",
|
|
73
|
+
engines: ["local", "s3", "gcs", "azure", "firebase"],
|
|
74
|
+
defaultEngine: "local",
|
|
75
|
+
envBases: ["S3_BUCKET", "GCS_BUCKET", "STORAGE_BUCKET", "STORAGE_PUBLIC_URL"],
|
|
76
|
+
envBasesByEngine: {
|
|
77
|
+
local: ["STORAGE_BUCKET"],
|
|
78
|
+
s3: ["S3_BUCKET", "STORAGE_ENDPOINT", "STORAGE_REGION", "STORAGE_PUBLIC_URL"],
|
|
79
|
+
gcs: ["GCS_BUCKET", "STORAGE_PUBLIC_URL"],
|
|
80
|
+
azure: ["STORAGE_BUCKET", "STORAGE_PUBLIC_URL"],
|
|
81
|
+
firebase: ["STORAGE_BUCKET", "STORAGE_PUBLIC_URL"]
|
|
82
|
+
},
|
|
83
|
+
optionKeys: ["publicRead", "prefix", "account"],
|
|
84
|
+
// Storage is genuinely optional: plenty of projects store nothing.
|
|
85
|
+
implicitDefault: false
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/** Options a bucket accepts beyond the common ones. */
|
|
89
|
+
export interface BucketOptions extends DeclareOptions {
|
|
90
|
+
/**
|
|
91
|
+
* Whether objects are world-readable by default.
|
|
92
|
+
*
|
|
93
|
+
* Declared rather than inferred from the engine, because the two have
|
|
94
|
+
* disagreed before: a private object served through a cacheable public URL
|
|
95
|
+
* is a data leak that nothing errors on.
|
|
96
|
+
*/
|
|
97
|
+
publicRead?: boolean;
|
|
98
|
+
/** Key prefix within the bucket, for sharing one bucket between sources. */
|
|
99
|
+
prefix?: string;
|
|
100
|
+
/**
|
|
101
|
+
* The credential set this bucket signs with, when several share one.
|
|
102
|
+
*
|
|
103
|
+
* `bucket("media", { engine: "s3", account: "minio" })` keeps reading its own
|
|
104
|
+
* `S3_BUCKET__MEDIA` — the bucket name is what distinguishes one source from
|
|
105
|
+
* another and never falls back — while the provider-level variables
|
|
106
|
+
* (`S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_ENDPOINT`, `S3_REGION`,
|
|
107
|
+
* `S3_FORCE_PATH_STYLE`) fall back to `__MINIO` when no per-key value is set.
|
|
108
|
+
*
|
|
109
|
+
* Fifteen buckets on one install go from ninety variables to eighteen, and
|
|
110
|
+
* rotating the key becomes one edit. A per-bucket value still wins, so a
|
|
111
|
+
* single source can move to another provider without breaking the rest off
|
|
112
|
+
* their shared account.
|
|
113
|
+
*/
|
|
114
|
+
account?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A bucket handle. Storage properties point at it via `storageSource`. */
|
|
118
|
+
export type BucketHandle = ResourceHandle;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Declare a bucket.
|
|
122
|
+
*
|
|
123
|
+
* ```ts
|
|
124
|
+
* export const media = bucket("media", { transport: "direct" });
|
|
125
|
+
* ```
|
|
126
|
+
*
|
|
127
|
+
* `transport: "direct"` means a provider SDK talks to the bucket and the
|
|
128
|
+
* backend is not in the upload path.
|
|
129
|
+
*/
|
|
130
|
+
export function bucket(key: string = DEFAULT_RESOURCE_KEY, options: BucketOptions = {}): BucketHandle {
|
|
131
|
+
return declareResource("bucket", key, options);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── topic ────────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
registerResourceKind({
|
|
137
|
+
kind: "topic",
|
|
138
|
+
// `jobs` is the durable local implementation: a topic fans out to one job
|
|
139
|
+
// row per subscription, so each subscriber retries on its own schedule and
|
|
140
|
+
// a failure is a row somebody can look at rather than a lost message.
|
|
141
|
+
engines: ["jobs"],
|
|
142
|
+
defaultEngine: "jobs",
|
|
143
|
+
envBases: ["REBASE_TOPIC_URL"],
|
|
144
|
+
optionKeys: ["delivery", "maxAttempts"],
|
|
145
|
+
implicitDefault: false
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* How hard the runtime tries to deliver.
|
|
150
|
+
*
|
|
151
|
+
* Only `at-least-once` is implemented, and it is the honest name for what a
|
|
152
|
+
* retrying queue does: a handler must tolerate seeing the same event twice.
|
|
153
|
+
* `at-most-once` is listed so a future transport can offer it without the
|
|
154
|
+
* option changing shape, and is refused today rather than silently upgraded.
|
|
155
|
+
*/
|
|
156
|
+
export type TopicDelivery = "at-least-once" | "at-most-once";
|
|
157
|
+
|
|
158
|
+
/** Options a topic accepts beyond the common ones. */
|
|
159
|
+
export interface TopicOptions extends DeclareOptions {
|
|
160
|
+
delivery?: TopicDelivery;
|
|
161
|
+
/** Attempts per subscription before a message is left failed. Default 5. */
|
|
162
|
+
maxAttempts?: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What a subscription does with an event.
|
|
167
|
+
*
|
|
168
|
+
* `attempt` counts from 1. Worth branching on: the first delivery and the
|
|
169
|
+
* fourth are the same call, but the fourth is where it is worth logging loudly.
|
|
170
|
+
*/
|
|
171
|
+
export type TopicHandler<T> = (event: T, context: { attempt: number; topic: string; subscription: string }) => Promise<void> | void;
|
|
172
|
+
|
|
173
|
+
/** A declared subscription, as recorded in the graph and wired at boot. */
|
|
174
|
+
export interface TopicSubscription<T = unknown> {
|
|
175
|
+
topic: string;
|
|
176
|
+
name: string;
|
|
177
|
+
handler: TopicHandler<T>;
|
|
178
|
+
maxAttempts?: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* What a topic publishes through.
|
|
183
|
+
*
|
|
184
|
+
* Installed by `@rebasepro/server` at boot. Absent — in the CLI evaluating
|
|
185
|
+
* config to derive the graph, or in a unit test — publishing throws a message
|
|
186
|
+
* naming the cause, rather than resolving and dropping the event. A publish
|
|
187
|
+
* that silently does nothing is the failure mode a queue exists to prevent.
|
|
188
|
+
*/
|
|
189
|
+
export interface TopicRuntime {
|
|
190
|
+
publish(topic: string, event: unknown): Promise<void>;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const runtimeHolder: { current: TopicRuntime | null } = { current: null };
|
|
194
|
+
|
|
195
|
+
/** Install the transport topics publish through. Called by the server at boot. */
|
|
196
|
+
export function setTopicRuntime(runtime: TopicRuntime | null): void {
|
|
197
|
+
runtimeHolder.current = runtime;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const subscriptions: TopicSubscription[] = [];
|
|
201
|
+
|
|
202
|
+
/** Every declared subscription, for the worker to wire and the graph to record. */
|
|
203
|
+
export function declaredSubscriptions(topic?: string): TopicSubscription[] {
|
|
204
|
+
return topic ? subscriptions.filter(s => s.topic === topic) : subscriptions.slice();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Forget declared subscriptions. For tests, alongside `resetDeclaredResources`. */
|
|
208
|
+
export function resetDeclaredSubscriptions(): void {
|
|
209
|
+
subscriptions.length = 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A topic handle, carrying its payload type. */
|
|
213
|
+
export interface TopicHandle<T> extends ResourceHandle {
|
|
214
|
+
/**
|
|
215
|
+
* Publish an event.
|
|
216
|
+
*
|
|
217
|
+
* Resolves once the event is durably recorded for every subscription, not
|
|
218
|
+
* once they have run. Enqueued inside a transaction that rolls back, it was
|
|
219
|
+
* never published.
|
|
220
|
+
*/
|
|
221
|
+
publish(event: T): Promise<void>;
|
|
222
|
+
/**
|
|
223
|
+
* Declare a subscription.
|
|
224
|
+
*
|
|
225
|
+
* The name is its identity: it is what the job row records, what a retry
|
|
226
|
+
* counts against, and what a second subscription must not collide with.
|
|
227
|
+
*/
|
|
228
|
+
subscription(name: string, handler: TopicHandler<T>, options?: { maxAttempts?: number }): void;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Declare a topic.
|
|
233
|
+
*
|
|
234
|
+
* ```ts
|
|
235
|
+
* export const signups = topic<{ userId: string }>("signups");
|
|
236
|
+
* signups.subscription("send-welcome", async (event) => { … });
|
|
237
|
+
* await signups.publish({ userId });
|
|
238
|
+
* ```
|
|
239
|
+
*/
|
|
240
|
+
export function topic<T = unknown>(key: string, options: TopicOptions = {}): TopicHandle<T> {
|
|
241
|
+
if (options.delivery === "at-most-once") {
|
|
242
|
+
throw new Error(
|
|
243
|
+
`Topic "${key}" asks for at-most-once delivery, which no shipped transport implements. ` +
|
|
244
|
+
"The durable queue behind topics retries, so it is at-least-once and a handler must " +
|
|
245
|
+
"tolerate seeing an event twice. Refused rather than quietly given the other guarantee."
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const handle = declareResource("topic", key, options);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
...handle,
|
|
252
|
+
toString() { return key; },
|
|
253
|
+
async publish(event: T): Promise<void> {
|
|
254
|
+
const runtime = runtimeHolder.current;
|
|
255
|
+
if (!runtime) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`Cannot publish to topic "${key}": no topic runtime is installed. ` +
|
|
258
|
+
"Publishing works inside a running Rebase backend; this looks like config " +
|
|
259
|
+
"being evaluated outside one (a build, a script, or a test without a harness)."
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
await runtime.publish(key, event);
|
|
263
|
+
},
|
|
264
|
+
subscription(name: string, handler: TopicHandler<T>, subOptions: { maxAttempts?: number } = {}): void {
|
|
265
|
+
if (!name || name.trim() === "") {
|
|
266
|
+
throw new Error(`A subscription on topic "${key}" needs a non-empty name.`);
|
|
267
|
+
}
|
|
268
|
+
if (subscriptions.some(s => s.topic === key && s.name === name)) {
|
|
269
|
+
throw new Error(
|
|
270
|
+
`Topic "${key}" already has a subscription named "${name}". ` +
|
|
271
|
+
"The name is what a job row records and what a retry counts against, so two " +
|
|
272
|
+
"cannot share one."
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
subscriptions.push({
|
|
276
|
+
topic: key,
|
|
277
|
+
name,
|
|
278
|
+
handler: handler as TopicHandler<unknown>,
|
|
279
|
+
...(subOptions.maxAttempts !== undefined ? { maxAttempts: subOptions.maxAttempts } : {})
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
} as TopicHandle<T>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Handing declarations to the frontend ─────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The declared databases, in the shape `<Rebase dataSources>` takes.
|
|
289
|
+
*
|
|
290
|
+
* The frontend needs to know which sources exist and how they are reached — a
|
|
291
|
+
* `direct`-transport source is one the browser talks to itself — and it imports
|
|
292
|
+
* the same config package the backend does. Without these it would mean writing
|
|
293
|
+
* the list a second time, by hand, next to the declarations, which is precisely
|
|
294
|
+
* the two-homes problem this model removed everywhere else.
|
|
295
|
+
*
|
|
296
|
+
* ```tsx
|
|
297
|
+
* import "../config/resources"; // registers them
|
|
298
|
+
* import { declaredDataSources, declaredStorageSources } from "@rebasepro/types";
|
|
299
|
+
*
|
|
300
|
+
* <Rebase dataSources={declaredDataSources()} storageSources={declaredStorageSources()} />
|
|
301
|
+
* ```
|
|
302
|
+
*
|
|
303
|
+
* The import is what registers them, so a bundler that drops an unused module
|
|
304
|
+
* would leave this empty — hence the side-effect import above rather than a
|
|
305
|
+
* bare re-export.
|
|
306
|
+
*/
|
|
307
|
+
export function declaredDataSources(): { key: string; engine: string; transport: ResourceTransport; label?: string }[] {
|
|
308
|
+
return declaredResources("database").map(r => ({
|
|
309
|
+
key: r.key,
|
|
310
|
+
engine: r.engine,
|
|
311
|
+
transport: r.transport,
|
|
312
|
+
...(r.label !== undefined ? { label: r.label } : {})
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The declared buckets, in the shape `<Rebase storageSources>` takes. */
|
|
317
|
+
export function declaredStorageSources(): { key: string; engine: string; transport: ResourceTransport; label?: string }[] {
|
|
318
|
+
return declaredResources("bucket").map(r => ({
|
|
319
|
+
key: r.key,
|
|
320
|
+
engine: r.engine,
|
|
321
|
+
transport: r.transport,
|
|
322
|
+
...(r.label !== undefined ? { label: r.label } : {})
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The resource graph: one declaration site for every named thing a project needs.
|
|
3
|
+
*
|
|
4
|
+
* ## The rule
|
|
5
|
+
*
|
|
6
|
+
* **Every named resource is declared with a constructor in config code.** A
|
|
7
|
+
* database, a bucket, a topic and whatever kind comes next are all spelled the
|
|
8
|
+
* same way, so "where do I declare my second one" has one answer instead of one
|
|
9
|
+
* answer per kind.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* export const main = database("main");
|
|
13
|
+
* export const media = bucket("media", { transport: "direct" });
|
|
14
|
+
* export const signups = topic<SignupEvent>("signups");
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* ## Declaration is not binding
|
|
18
|
+
*
|
|
19
|
+
* A declaration says a resource *exists* and what shape it has. It never says
|
|
20
|
+
* how to reach it — that is a property of the environment, not of the project,
|
|
21
|
+
* and it differs between a laptop, a self-hosted box and a tenant in the cloud.
|
|
22
|
+
* Binding lives in `@rebasepro/server`'s boot path, reading environment
|
|
23
|
+
* variables or an infrastructure config file, and it keys off the logical name
|
|
24
|
+
* declared here.
|
|
25
|
+
*
|
|
26
|
+
* This split is the whole point. Before it, storage topology was hand-written
|
|
27
|
+
* into `rebase.json` while database topology lived in TypeScript, and the
|
|
28
|
+
* boundary between them was a fact about what the control plane could read
|
|
29
|
+
* before a build — a platform implementation detail that a developer had no way
|
|
30
|
+
* to derive. Worse, storage could be declared in *both* places, and the merge
|
|
31
|
+
* silently kept the JSON's engine and discarded the code's.
|
|
32
|
+
*
|
|
33
|
+
* ## Why a registry rather than a fixed union
|
|
34
|
+
*
|
|
35
|
+
* Kinds register themselves. Adding pub/sub, a cache or a search index must not
|
|
36
|
+
* require editing a manifest schema, a validator and three switch statements —
|
|
37
|
+
* that cost is exactly why the last two kinds ended up in different homes.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** How a client reaches a resource. */
|
|
41
|
+
export type ResourceTransport =
|
|
42
|
+
/** Through the backend. The default, and the only one that needs no client SDK. */
|
|
43
|
+
| "server"
|
|
44
|
+
/** A provider SDK talks to the resource directly; the backend is not in the path. */
|
|
45
|
+
| "direct";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A resource kind, as registered.
|
|
49
|
+
*
|
|
50
|
+
* `engines` is an allowlist rather than documentation. An unrecognised engine
|
|
51
|
+
* used to be a free string that passed every check and failed later, further
|
|
52
|
+
* from the typo that caused it — `"s2"` for `"s3"` reached the runtime. Anything
|
|
53
|
+
* genuinely outside the list is spelled `custom:<id>`, which says so at the call
|
|
54
|
+
* site instead of looking like a typo.
|
|
55
|
+
*/
|
|
56
|
+
export interface ResourceKindSpec {
|
|
57
|
+
/** The kind's name, as it appears in a declaration and in the graph. */
|
|
58
|
+
kind: string;
|
|
59
|
+
/** Engines this kind ships with. `custom:<id>` is always additionally valid. */
|
|
60
|
+
engines: readonly string[];
|
|
61
|
+
/** Used when a declaration names none. */
|
|
62
|
+
defaultEngine: string;
|
|
63
|
+
/**
|
|
64
|
+
* Environment variable base names this kind binds from, in the order a
|
|
65
|
+
* binder should try them. A resource keyed `analytics` reads
|
|
66
|
+
* `<BASE>__ANALYTICS`; the default-keyed resource reads `<BASE>` unsuffixed,
|
|
67
|
+
* so a single-resource project configured the obvious way declares nothing.
|
|
68
|
+
*/
|
|
69
|
+
envBases: readonly string[];
|
|
70
|
+
/**
|
|
71
|
+
* The subset of `envBases` that matters for a given engine.
|
|
72
|
+
*
|
|
73
|
+
* The binder reads every base and takes whichever is set — harmless, and it
|
|
74
|
+
* keeps binding tolerant. A GENERATOR cannot be that relaxed: `rebase eject
|
|
75
|
+
* infra` writing S3_BUCKET, GCS_BUCKET, STORAGE_BUCKET and
|
|
76
|
+
* STORAGE_PUBLIC_URL for a `local` bucket hands somebody four variables of
|
|
77
|
+
* which three are noise, and a config file full of irrelevant keys is one
|
|
78
|
+
* nobody reads carefully.
|
|
79
|
+
*
|
|
80
|
+
* Keyed by engine; an engine with no entry falls back to all of them, which
|
|
81
|
+
* is the honest answer for one this package has never heard of.
|
|
82
|
+
*/
|
|
83
|
+
envBasesByEngine?: Readonly<Record<string, readonly string[]>>;
|
|
84
|
+
/** Option keys this kind accepts beyond the common ones, for validation. */
|
|
85
|
+
optionKeys?: readonly string[];
|
|
86
|
+
/**
|
|
87
|
+
* Whether a project implicitly has one of these even when it declares
|
|
88
|
+
* nothing. True for databases — a backend without one is not a backend —
|
|
89
|
+
* and false for topics, where zero is the normal number.
|
|
90
|
+
*/
|
|
91
|
+
implicitDefault?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The key a resource takes when a project declares only one of its kind. */
|
|
95
|
+
export const DEFAULT_RESOURCE_KEY = "(default)";
|
|
96
|
+
|
|
97
|
+
/** A declared resource, as it appears in the graph. */
|
|
98
|
+
export interface ResourceDeclaration {
|
|
99
|
+
kind: string;
|
|
100
|
+
/** Unique within its kind. What a binder looks up and what an env suffix is built from. */
|
|
101
|
+
key: string;
|
|
102
|
+
engine: string;
|
|
103
|
+
transport: ResourceTransport;
|
|
104
|
+
label?: string;
|
|
105
|
+
/** Kind-specific options, validated against the kind's `optionKeys`. */
|
|
106
|
+
options: Readonly<Record<string, unknown>>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The value a constructor returns.
|
|
111
|
+
*
|
|
112
|
+
* Carries its own declaration so config code can hold it and pass it around,
|
|
113
|
+
* and stringifies to its key so it drops into the places that still take one.
|
|
114
|
+
* Collections name a data source by string today; a handle works there without
|
|
115
|
+
* the collection API having to change, which keeps this a config redesign
|
|
116
|
+
* rather than a rewrite of the data layer.
|
|
117
|
+
*/
|
|
118
|
+
export interface ResourceHandle extends ResourceDeclaration {
|
|
119
|
+
toString(): string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const BRAND = Symbol.for("@rebasepro/types.resource");
|
|
123
|
+
|
|
124
|
+
/** Whether a value is a resource handle rather than a plain string key. */
|
|
125
|
+
export function isResourceHandle(value: unknown): value is ResourceHandle {
|
|
126
|
+
return typeof value === "object" && value !== null && BRAND in value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The key a resource reference names, whether it is a handle or already a key. */
|
|
130
|
+
export function resourceKeyOf(ref: string | ResourceHandle): string {
|
|
131
|
+
return isResourceHandle(ref) ? ref.key : ref;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The process-wide registry.
|
|
136
|
+
*
|
|
137
|
+
* Keyed off `globalThis` through a shared symbol rather than held in a module
|
|
138
|
+
* local, because a module local is per *copy* of this package. A project that
|
|
139
|
+
* ends up with two copies of `@rebasepro/types` — which a partially-linked
|
|
140
|
+
* `node_modules` produces, and which has already caused a phantom
|
|
141
|
+
* "JWT secret not configured" bug in this repo — would otherwise register into
|
|
142
|
+
* one registry and read from the other, and see an empty graph with nothing
|
|
143
|
+
* anywhere to explain it.
|
|
144
|
+
*/
|
|
145
|
+
interface Registry {
|
|
146
|
+
kinds: Map<string, ResourceKindSpec>;
|
|
147
|
+
declarations: Map<string, ResourceDeclaration>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const GLOBAL_KEY = Symbol.for("@rebasepro/types.resourceRegistry");
|
|
151
|
+
|
|
152
|
+
function registry(): Registry {
|
|
153
|
+
const g = globalThis as unknown as Record<symbol, Registry | undefined>;
|
|
154
|
+
let existing = g[GLOBAL_KEY];
|
|
155
|
+
if (!existing) {
|
|
156
|
+
existing = { kinds: new Map(), declarations: new Map() };
|
|
157
|
+
g[GLOBAL_KEY] = existing;
|
|
158
|
+
}
|
|
159
|
+
return existing;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `kind:key`, the graph's primary key. */
|
|
163
|
+
function declarationId(kind: string, key: string): string {
|
|
164
|
+
return `${kind}:${key}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Register a resource kind. Idempotent for an identical spec; throws on a conflicting one. */
|
|
168
|
+
export function registerResourceKind(spec: ResourceKindSpec): void {
|
|
169
|
+
const existing = registry().kinds.get(spec.kind);
|
|
170
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(spec)) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Resource kind "${spec.kind}" is already registered with a different definition. ` +
|
|
173
|
+
"Two packages cannot define the same kind."
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
registry().kinds.set(spec.kind, spec);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Every registered kind, for validators and for `rebase doctor`. */
|
|
180
|
+
export function resourceKinds(): ResourceKindSpec[] {
|
|
181
|
+
return [...registry().kinds.values()];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** One registered kind, or undefined. */
|
|
185
|
+
export function resourceKind(kind: string): ResourceKindSpec | undefined {
|
|
186
|
+
return registry().kinds.get(kind);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Options every kind accepts. */
|
|
190
|
+
export interface DeclareOptions {
|
|
191
|
+
engine?: string;
|
|
192
|
+
transport?: ResourceTransport;
|
|
193
|
+
label?: string;
|
|
194
|
+
[option: string]: unknown;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const COMMON_OPTION_KEYS = ["engine", "transport", "label"] as const;
|
|
198
|
+
|
|
199
|
+
/** Whether an engine is one the kind knows, or an explicit `custom:` opt-out. */
|
|
200
|
+
export function isValidEngine(spec: ResourceKindSpec, engine: string): boolean {
|
|
201
|
+
return engine.startsWith("custom:") || spec.engines.includes(engine);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Declare a resource. The primitive every kind's constructor is built from.
|
|
206
|
+
*
|
|
207
|
+
* Redeclaring the same `kind:key` with a *different* shape throws rather than
|
|
208
|
+
* merging. Merging is what the old storage path did, and it silently discarded
|
|
209
|
+
* one of the two engines — a declaration accepted and then ignored, which is
|
|
210
|
+
* the failure this whole model exists to remove. Redeclaring it identically is
|
|
211
|
+
* fine: a config module evaluated twice must not be an error.
|
|
212
|
+
*/
|
|
213
|
+
export function declareResource(
|
|
214
|
+
kind: string,
|
|
215
|
+
key: string = DEFAULT_RESOURCE_KEY,
|
|
216
|
+
options: DeclareOptions = {}
|
|
217
|
+
): ResourceHandle {
|
|
218
|
+
const spec = registry().kinds.get(kind);
|
|
219
|
+
if (!spec) {
|
|
220
|
+
const known = [...registry().kinds.keys()].sort().join(", ") || "none";
|
|
221
|
+
throw new Error(
|
|
222
|
+
`Unknown resource kind "${kind}". Registered kinds: ${known}. ` +
|
|
223
|
+
"Call registerResourceKind() before declaring one."
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!key || typeof key !== "string" || key.trim() === "") {
|
|
228
|
+
throw new Error(`A ${kind} needs a non-empty key.`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const engine = options.engine ?? spec.defaultEngine;
|
|
232
|
+
if (!isValidEngine(spec, engine)) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`Unknown ${kind} engine "${engine}" for "${key}". ` +
|
|
235
|
+
`Known engines: ${spec.engines.join(", ")}. ` +
|
|
236
|
+
`An engine this build does not ship is spelled "custom:${engine}", ` +
|
|
237
|
+
"which says so at the call site rather than failing later."
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const allowed = new Set<string>([...COMMON_OPTION_KEYS, ...(spec.optionKeys ?? [])]);
|
|
242
|
+
const unknown = Object.keys(options).filter(k => !allowed.has(k));
|
|
243
|
+
if (unknown.length > 0) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Unknown option(s) on ${kind} "${key}": ${unknown.join(", ")}. ` +
|
|
246
|
+
`A ${kind} accepts: ${[...allowed].sort().join(", ")}.`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const extra: Record<string, unknown> = {};
|
|
251
|
+
for (const k of spec.optionKeys ?? []) {
|
|
252
|
+
if (options[k] !== undefined) extra[k] = options[k];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const declaration: ResourceDeclaration = {
|
|
256
|
+
kind,
|
|
257
|
+
key,
|
|
258
|
+
engine,
|
|
259
|
+
transport: options.transport ?? "server",
|
|
260
|
+
...(options.label !== undefined ? { label: options.label } : {}),
|
|
261
|
+
options: Object.freeze(extra)
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const id = declarationId(kind, key);
|
|
265
|
+
const previous = registry().declarations.get(id);
|
|
266
|
+
if (previous) {
|
|
267
|
+
if (JSON.stringify(previous) !== JSON.stringify(declaration)) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`${kind} "${key}" is declared twice with different configuration. ` +
|
|
270
|
+
"Declare it once and export it — two declarations of one resource is " +
|
|
271
|
+
"the ambiguity this model exists to remove, so it is refused rather " +
|
|
272
|
+
"than merged."
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
registry().declarations.set(id, declaration);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const handle = {
|
|
280
|
+
...declaration,
|
|
281
|
+
toString() { return key; },
|
|
282
|
+
[BRAND]: true as const
|
|
283
|
+
};
|
|
284
|
+
return handle as ResourceHandle;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Every declared resource, in declaration order, optionally filtered by kind. */
|
|
288
|
+
export function declaredResources(kind?: string): ResourceDeclaration[] {
|
|
289
|
+
const all = [...registry().declarations.values()];
|
|
290
|
+
return kind ? all.filter(r => r.kind === kind) : all;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Forget every declaration, keeping registered kinds.
|
|
295
|
+
*
|
|
296
|
+
* For tests and for a CLI that evaluates more than one project in a process.
|
|
297
|
+
* Kinds survive because they are registered by module import, which will not
|
|
298
|
+
* happen a second time.
|
|
299
|
+
*/
|
|
300
|
+
export function resetDeclaredResources(): void {
|
|
301
|
+
registry().declarations.clear();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The env-var suffix a resource's bindings use: `__ANALYTICS` for `analytics`,
|
|
306
|
+
* and nothing at all for the default-keyed one.
|
|
307
|
+
*
|
|
308
|
+
* The default takes no suffix so that a project with one database configured
|
|
309
|
+
* through plain `DATABASE_URL` keeps working having declared nothing — the
|
|
310
|
+
* overwhelmingly common project must not have to say so.
|
|
311
|
+
*/
|
|
312
|
+
export function resourceEnvSuffix(key: string): string {
|
|
313
|
+
if (key === DEFAULT_RESOURCE_KEY) return "";
|
|
314
|
+
return `__${key.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "")}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Two resources of a kind whose keys differ but whose env suffixes do not.
|
|
319
|
+
*
|
|
320
|
+
* `media-files` and `media_files` both become `__MEDIA_FILES`, so one would
|
|
321
|
+
* silently read the other's configuration. Returned rather than thrown so the
|
|
322
|
+
* caller can report it with the rest of a validation pass.
|
|
323
|
+
*/
|
|
324
|
+
export function findEnvSuffixCollision(keys: readonly string[]): { a: string; b: string; suffix: string } | null {
|
|
325
|
+
const seen = new Map<string, string>();
|
|
326
|
+
for (const key of keys) {
|
|
327
|
+
const suffix = resourceEnvSuffix(key);
|
|
328
|
+
const previous = seen.get(suffix);
|
|
329
|
+
if (previous !== undefined && previous !== key) return { a: previous, b: key, suffix };
|
|
330
|
+
seen.set(suffix, key);
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The whole graph, as recorded in a manifest and read by a host.
|
|
337
|
+
*
|
|
338
|
+
* `version` is the graph format, not the project's. A host reading a graph it
|
|
339
|
+
* does not understand must say so rather than provision half of it.
|
|
340
|
+
*/
|
|
341
|
+
export interface ResourceGraph {
|
|
342
|
+
version: 1;
|
|
343
|
+
resources: ResourceDeclaration[];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The current graph format version. */
|
|
347
|
+
export const RESOURCE_GRAPH_VERSION = 1 as const;
|
|
348
|
+
|
|
349
|
+
/** Build a graph from the current declarations, sorted for a stable diff. */
|
|
350
|
+
export function buildResourceGraph(): ResourceGraph {
|
|
351
|
+
const resources = declaredResources().slice().sort(
|
|
352
|
+
(a, b) => a.kind.localeCompare(b.kind) || a.key.localeCompare(b.key)
|
|
353
|
+
);
|
|
354
|
+
return { version: RESOURCE_GRAPH_VERSION, resources };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The environment variables worth writing for a resource, given its engine.
|
|
359
|
+
*
|
|
360
|
+
* Falls back to every base the kind reads when the engine is unknown — a
|
|
361
|
+
* `custom:` engine gets the full list rather than an empty one, because
|
|
362
|
+
* guessing narrow would silently omit the variable it actually needs.
|
|
363
|
+
*/
|
|
364
|
+
export function envBasesForResource(declaration: ResourceDeclaration): readonly string[] {
|
|
365
|
+
const spec = resourceKind(declaration.kind);
|
|
366
|
+
if (!spec) return [];
|
|
367
|
+
return spec.envBasesByEngine?.[declaration.engine] ?? spec.envBases;
|
|
368
|
+
}
|