@getstrata/starter 2.0.9 → 2.2.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/cli.js
CHANGED
|
@@ -2905,6 +2905,8 @@ function renderEnvExample(projectName, layers) {
|
|
|
2905
2905
|
`APP_KEY_PREFIX=${projectName}`,
|
|
2906
2906
|
"APP_ENV=local",
|
|
2907
2907
|
"PORT=3000",
|
|
2908
|
+
"# Total graceful shutdown budget in milliseconds; keep deployment grace longer.",
|
|
2909
|
+
"SHUTDOWN_TIMEOUT_MS=30000",
|
|
2908
2910
|
"APP_URL=http://localhost:3000",
|
|
2909
2911
|
`DATABASE_URL=${defaultDatabaseUrl(layers, projectName)}`,
|
|
2910
2912
|
...postgresAppRoleEnvNotes(layers),
|
|
@@ -2922,6 +2924,8 @@ function renderEnvExample(projectName, layers) {
|
|
|
2922
2924
|
`MAIL_DRIVER=${layers.mail}`,
|
|
2923
2925
|
`AUTH_DEV_HEADERS=${envFlag(layers.auth === "headers")}`
|
|
2924
2926
|
];
|
|
2927
|
+
lines.push("# Scheduler coordination defaults to Redis in production, one process in development.");
|
|
2928
|
+
lines.push("# SCHEDULER_COORDINATION=redis");
|
|
2925
2929
|
lines.push("APP_DEBUG=false");
|
|
2926
2930
|
lines.push("FEATURE_PUBLIC_READS=false");
|
|
2927
2931
|
lines.push("# wrapWebPublicRead / wrapPublicRead require login when false (production-safe default).");
|
|
@@ -3191,9 +3195,9 @@ function renderPackageJson(projectName, options = {}) {
|
|
|
3191
3195
|
"@getstrata/cli": "workspace:*",
|
|
3192
3196
|
"@getstrata/core": "workspace:*"
|
|
3193
3197
|
} : {
|
|
3194
|
-
"@getstrata/bootstrap": "^2.0
|
|
3195
|
-
"@getstrata/cli": "^2.0
|
|
3196
|
-
"@getstrata/core": "^2.0
|
|
3198
|
+
"@getstrata/bootstrap": "^2.2.0",
|
|
3199
|
+
"@getstrata/cli": "^2.2.0",
|
|
3200
|
+
"@getstrata/core": "^2.2.0"
|
|
3197
3201
|
};
|
|
3198
3202
|
coreDeps.eta = "^4.6.0";
|
|
3199
3203
|
if (options.layers?.database === "mysql") {
|
|
@@ -3384,7 +3388,7 @@ function renderIntegrationsReadme(layers) {
|
|
|
3384
3388
|
"",
|
|
3385
3389
|
"OAuth, billing, outbound webhooks, SIEM, and SAML are **off by default**. Commented placeholders live in `.env.example`. Framework map: [INTEGRATIONS.md](https://github.com/EyK-26/strata/blob/main/docs/INTEGRATIONS.md).",
|
|
3386
3390
|
"",
|
|
3387
|
-
"Wizard flags (any stack unless noted): `--oauth-github` and `--oidc` (cookie HTML), `--billing`, `--webhooks`. They emit modules, migrations, jobs, or listeners. `createApp` still calls `discoverListeners()` and `discoverJobs()`.",
|
|
3391
|
+
"Wizard flags (any stack unless noted): `--oauth-github` and `--oidc` (cookie HTML), `--billing`, `--webhooks`. They emit modules, migrations, jobs, or listeners. `createApp` still calls `await discoverListeners()` and `await discoverJobs()`.",
|
|
3388
3392
|
""
|
|
3389
3393
|
];
|
|
3390
3394
|
if (enabled.length > 0) {
|
|
@@ -3780,9 +3784,7 @@ function starterSchemaStatements(layers) {
|
|
|
3780
3784
|
if (tenancyOn) {
|
|
3781
3785
|
statements.push(`CREATE TABLE IF NOT EXISTS tenant (
|
|
3782
3786
|
id ${d.id},
|
|
3783
|
-
slug ${d.keyText} NOT NULL UNIQUE
|
|
3784
|
-
plan ${d.defaultText} NOT NULL DEFAULT 'free',
|
|
3785
|
-
region ${d.defaultText} NOT NULL DEFAULT 'eu'
|
|
3787
|
+
slug ${d.keyText} NOT NULL UNIQUE
|
|
3786
3788
|
)`);
|
|
3787
3789
|
}
|
|
3788
3790
|
const notesTenantColumn = tenancyOn ? `
|
|
@@ -3953,8 +3955,8 @@ function renderMigrateTs(layers) {
|
|
|
3953
3955
|
);
|
|
3954
3956
|
if (Number(tenantCount) === 0) {
|
|
3955
3957
|
await sql.unsafe(
|
|
3956
|
-
"INSERT INTO tenant (slug
|
|
3957
|
-
["default"
|
|
3958
|
+
"INSERT INTO tenant (slug) VALUES (${ph ? "$1" : "?"})",
|
|
3959
|
+
["default"],
|
|
3958
3960
|
);
|
|
3959
3961
|
}` : "";
|
|
3960
3962
|
const seedUsers = authNeedsUsers(layers.auth) ? `
|
|
@@ -4030,8 +4032,7 @@ function renderNoteModel(layers) {
|
|
|
4030
4032
|
tenant_id: number;` : "";
|
|
4031
4033
|
const tenantColumn = tenancyOn ? ', "tenant_id"' : "";
|
|
4032
4034
|
const fillable = tenancyOn ? '["body", "tenant_id"]' : '["body"]';
|
|
4033
|
-
return `import {
|
|
4034
|
-
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
4035
|
+
return `import { defineModel } from "@getstrata/core/database/model";
|
|
4035
4036
|
import { defineTable } from "@getstrata/core/database/table";
|
|
4036
4037
|
|
|
4037
4038
|
interface NoteRecord {
|
|
@@ -4047,21 +4048,13 @@ const notesTable = defineTable<NoteRecord, "id">({
|
|
|
4047
4048
|
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
4048
4049
|
});
|
|
4049
4050
|
|
|
4050
|
-
class
|
|
4051
|
-
constructor() {
|
|
4052
|
-
super(notesTable);
|
|
4053
|
-
}
|
|
4054
|
-
}
|
|
4055
|
-
|
|
4056
|
-
class Note extends Model<NoteRecord, "id"> {
|
|
4051
|
+
class Note extends defineModel(notesTable) {
|
|
4057
4052
|
static $fillable = ${fillable} as const;
|
|
4058
4053
|
// created_at uses the table default. Sending a JS Date from $timestamps
|
|
4059
4054
|
// is rejected by SQLite bindings.
|
|
4060
4055
|
static $timestamps = false;
|
|
4061
4056
|
}
|
|
4062
4057
|
|
|
4063
|
-
registerModelRepository(Note, new NoteRepository());
|
|
4064
|
-
|
|
4065
4058
|
export type { NoteRecord };
|
|
4066
4059
|
export { Note };
|
|
4067
4060
|
`;
|
|
@@ -4098,8 +4091,7 @@ function renderUserModel(layers) {
|
|
|
4098
4091
|
const fillableLiteral = `[${fillable.map((column) => `"${column}"`).join(", ")}]`;
|
|
4099
4092
|
const hiddenLiteral = `[${hidden.map((column) => `"${column}"`).join(", ")}]`;
|
|
4100
4093
|
const columnsLiteral = columns.map((column) => `"${column}"`).join(", ");
|
|
4101
|
-
return `import {
|
|
4102
|
-
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
4094
|
+
return `import { defineModel } from "@getstrata/core/database/model";
|
|
4103
4095
|
import { defineTable } from "@getstrata/core/database/table";
|
|
4104
4096
|
|
|
4105
4097
|
interface UserRecord {
|
|
@@ -4120,13 +4112,7 @@ const usersTable = defineTable<UserRecord, "id">({
|
|
|
4120
4112
|
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
4121
4113
|
});
|
|
4122
4114
|
|
|
4123
|
-
class
|
|
4124
|
-
constructor() {
|
|
4125
|
-
super(usersTable);
|
|
4126
|
-
}
|
|
4127
|
-
}
|
|
4128
|
-
|
|
4129
|
-
class User extends Model<UserRecord, "id"> {
|
|
4115
|
+
class User extends defineModel(usersTable) {
|
|
4130
4116
|
static $fillable = ${fillableLiteral} as const;
|
|
4131
4117
|
static $hidden = ${hiddenLiteral} as const;
|
|
4132
4118
|
// created_at uses the table default. Sending a JS Date from $timestamps
|
|
@@ -4134,15 +4120,12 @@ class User extends Model<UserRecord, "id"> {
|
|
|
4134
4120
|
static $timestamps = false;
|
|
4135
4121
|
}
|
|
4136
4122
|
|
|
4137
|
-
registerModelRepository(User, new UserRepository());
|
|
4138
|
-
|
|
4139
4123
|
export type { UserRecord };
|
|
4140
4124
|
export { User };
|
|
4141
4125
|
`;
|
|
4142
4126
|
}
|
|
4143
4127
|
function renderApiTokenModel() {
|
|
4144
|
-
return `import {
|
|
4145
|
-
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
4128
|
+
return `import { defineModel } from "@getstrata/core/database/model";
|
|
4146
4129
|
import { defineTable } from "@getstrata/core/database/table";
|
|
4147
4130
|
|
|
4148
4131
|
interface ApiTokenRecord {
|
|
@@ -4163,28 +4146,19 @@ const apiTokensTable = defineTable<ApiTokenRecord, "id">({
|
|
|
4163
4146
|
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
4164
4147
|
});
|
|
4165
4148
|
|
|
4166
|
-
class
|
|
4167
|
-
constructor() {
|
|
4168
|
-
super(apiTokensTable);
|
|
4169
|
-
}
|
|
4170
|
-
}
|
|
4171
|
-
|
|
4172
|
-
class ApiToken extends Model<ApiTokenRecord, "id"> {
|
|
4149
|
+
class ApiToken extends defineModel(apiTokensTable) {
|
|
4173
4150
|
static $fillable = ["user_id", "name", "token_hash", "abilities", "expires_at", "last_used_at"] as const;
|
|
4174
4151
|
// created_at uses the table default. Sending a JS Date from $timestamps
|
|
4175
4152
|
// is rejected by SQLite bindings.
|
|
4176
4153
|
static $timestamps = false;
|
|
4177
4154
|
}
|
|
4178
4155
|
|
|
4179
|
-
registerModelRepository(ApiToken, new ApiTokenRepository());
|
|
4180
|
-
|
|
4181
4156
|
export type { ApiTokenRecord };
|
|
4182
4157
|
export { ApiToken };
|
|
4183
4158
|
`;
|
|
4184
4159
|
}
|
|
4185
4160
|
function renderAuthOneTimeTokenModel() {
|
|
4186
|
-
return `import {
|
|
4187
|
-
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
4161
|
+
return `import { defineModel } from "@getstrata/core/database/model";
|
|
4188
4162
|
import { defineTable } from "@getstrata/core/database/table";
|
|
4189
4163
|
|
|
4190
4164
|
interface AuthOneTimeTokenRecord {
|
|
@@ -4203,44 +4177,20 @@ const authOneTimeTokensTable = defineTable<AuthOneTimeTokenRecord, "id">({
|
|
|
4203
4177
|
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
4204
4178
|
});
|
|
4205
4179
|
|
|
4206
|
-
class
|
|
4207
|
-
constructor() {
|
|
4208
|
-
super(authOneTimeTokensTable);
|
|
4209
|
-
}
|
|
4210
|
-
}
|
|
4211
|
-
|
|
4212
|
-
class AuthOneTimeToken extends Model<AuthOneTimeTokenRecord, "id"> {
|
|
4180
|
+
class AuthOneTimeToken extends defineModel(authOneTimeTokensTable) {
|
|
4213
4181
|
static $fillable = ["purpose", "user_id", "token_hash", "expires_at", "consumed_at"] as const;
|
|
4214
4182
|
static $timestamps = false;
|
|
4215
4183
|
}
|
|
4216
4184
|
|
|
4217
|
-
registerModelRepository(AuthOneTimeToken, new AuthOneTimeTokenRepository());
|
|
4218
|
-
|
|
4219
4185
|
export type { AuthOneTimeTokenRecord };
|
|
4220
4186
|
export { AuthOneTimeToken };
|
|
4221
4187
|
`;
|
|
4222
4188
|
}
|
|
4223
|
-
function renderModelsRegister(
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
'import "./Note.ts";'
|
|
4229
|
-
];
|
|
4230
|
-
if (authNeedsUsers(layers.auth)) {
|
|
4231
|
-
lines.push('import "./User.ts";');
|
|
4232
|
-
lines.push('import "./AuthOneTimeToken.ts";');
|
|
4233
|
-
}
|
|
4234
|
-
if (authUsesToken(layers.auth)) {
|
|
4235
|
-
lines.push('import "./ApiToken.ts";');
|
|
4236
|
-
}
|
|
4237
|
-
lines.push("");
|
|
4238
|
-
lines.push("// Example after you add a Product model with belongsTo('Category'):");
|
|
4239
|
-
lines.push('// registerModelClass("Category", Category);');
|
|
4240
|
-
lines.push("void registerModelClass;");
|
|
4241
|
-
lines.push("");
|
|
4242
|
-
return `${lines.join(`
|
|
4243
|
-
`)}
|
|
4189
|
+
function renderModelsRegister(_layers) {
|
|
4190
|
+
return `import { discoverModels } from "@getstrata/bootstrap/discoverModels";
|
|
4191
|
+
|
|
4192
|
+
// Discovery binds default repositories and registers every name before model boot.
|
|
4193
|
+
await discoverModels(import.meta.dir);
|
|
4244
4194
|
`;
|
|
4245
4195
|
}
|
|
4246
4196
|
function dropTables(layers) {
|
|
@@ -4440,17 +4390,15 @@ import {
|
|
|
4440
4390
|
|
|
4441
4391
|
const queueProvider: ServiceProvider = {
|
|
4442
4392
|
name: "starter.queue",
|
|
4443
|
-
register({ container }) {
|
|
4393
|
+
async register({ container, onCleanup }) {
|
|
4444
4394
|
const driver = (process.env.QUEUE_DRIVER ?? "sync") as "sync" | "async" | "redis";
|
|
4445
4395
|
const failedJobs = createFailedJobService();
|
|
4446
4396
|
container.set(FAILED_JOB_SERVICE_TOKEN, failedJobs);
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
}),
|
|
4453
|
-
);
|
|
4397
|
+
registerDefaultJobs();
|
|
4398
|
+
await discoverJobs();
|
|
4399
|
+
const queue = createAppQueue(driver, process.env.REDIS_URL, failedJobs);
|
|
4400
|
+
onCleanup(() => queue.close?.(), driver === "async" ? "drain" : "close");
|
|
4401
|
+
container.set(CORE_QUEUE_TOKEN, queue);
|
|
4454
4402
|
},
|
|
4455
4403
|
};
|
|
4456
4404
|
|
|
@@ -4554,7 +4502,8 @@ export default policyProvider;
|
|
|
4554
4502
|
`;
|
|
4555
4503
|
}
|
|
4556
4504
|
function renderProvidersIndex() {
|
|
4557
|
-
return `import {
|
|
4505
|
+
return `import { tracingProvider } from "@getstrata/bootstrap";
|
|
4506
|
+
import { discoverListeners } from "@getstrata/bootstrap/discoverListeners";
|
|
4558
4507
|
import { registerInvalidateCacheOnModelWriteListeners } from "@getstrata/bootstrap/listeners/invalidateCacheOnModelWrite";
|
|
4559
4508
|
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
4560
4509
|
import authProvider from "./auth.ts";
|
|
@@ -4564,32 +4513,20 @@ import policyProvider from "./policy.ts";
|
|
|
4564
4513
|
import queueProvider from "./queue.ts";
|
|
4565
4514
|
import storageProvider from "./storage.ts";
|
|
4566
4515
|
|
|
4567
|
-
const registeredListenerGroups = new Set<string>();
|
|
4568
|
-
|
|
4569
|
-
function registerListenerGroup(name: string, register: () => void): void {
|
|
4570
|
-
if (registeredListenerGroups.has(name)) {
|
|
4571
|
-
return;
|
|
4572
|
-
}
|
|
4573
|
-
|
|
4574
|
-
registeredListenerGroups.add(name);
|
|
4575
|
-
register();
|
|
4576
|
-
}
|
|
4577
|
-
|
|
4578
4516
|
const listenersProvider: ServiceProvider = {
|
|
4579
4517
|
name: "starter.listeners",
|
|
4580
|
-
boot() {
|
|
4581
|
-
|
|
4582
|
-
registerInvalidateCacheOnModelWriteListeners();
|
|
4583
|
-
});
|
|
4518
|
+
async boot({ onCleanup }) {
|
|
4519
|
+
onCleanup(registerInvalidateCacheOnModelWriteListeners());
|
|
4584
4520
|
|
|
4585
|
-
for (const registerListener of discoverListeners()) {
|
|
4586
|
-
registerListener();
|
|
4521
|
+
for (const registerListener of await discoverListeners()) {
|
|
4522
|
+
await registerListener();
|
|
4587
4523
|
}
|
|
4588
4524
|
},
|
|
4589
4525
|
};
|
|
4590
4526
|
|
|
4591
4527
|
const starterProviders: ServiceProvider[] = [
|
|
4592
4528
|
configProvider,
|
|
4529
|
+
tracingProvider,
|
|
4593
4530
|
cacheProvider,
|
|
4594
4531
|
storageProvider,
|
|
4595
4532
|
queueProvider,
|
|
@@ -4610,17 +4547,8 @@ function renderCreateAppTs(layers) {
|
|
|
4610
4547
|
...createMetricsRoutes(),` : "";
|
|
4611
4548
|
return `import { join } from "node:path";
|
|
4612
4549
|
import "./preload.ts";
|
|
4613
|
-
import {
|
|
4614
|
-
import {
|
|
4615
|
-
type AppContext,
|
|
4616
|
-
type AppDependencies,
|
|
4617
|
-
type AppRouteMap,
|
|
4618
|
-
assertAppDependenciesComplete,
|
|
4619
|
-
type ConfigStore,
|
|
4620
|
-
type MutableAppDependencies,
|
|
4621
|
-
type ProviderContext,
|
|
4622
|
-
ServiceContainer,
|
|
4623
|
-
} from "@getstrata/bootstrap/contracts";
|
|
4550
|
+
import { createAppContext as createProviderAppContext, type InitializedAppContext } from "@getstrata/bootstrap/context";
|
|
4551
|
+
import type { AppRouteMap } from "@getstrata/bootstrap/contracts";
|
|
4624
4552
|
import { mergeSpaRoutes } from "@getstrata/bootstrap/createSpaRoutes";
|
|
4625
4553
|
import {
|
|
4626
4554
|
configureModulesDirectory,
|
|
@@ -4630,13 +4558,13 @@ import {
|
|
|
4630
4558
|
import { createHealthRoutes } from "@getstrata/bootstrap/health";
|
|
4631
4559
|
${metricsImport}import { assertProductionSecrets, assertRlsLiveDatabaseRole } from "@getstrata/bootstrap/secretsGuard";
|
|
4632
4560
|
import { createWebServer } from "@getstrata/bootstrap/web/server";
|
|
4633
|
-
import {
|
|
4561
|
+
import type { LifecycleCoordinator } from "@getstrata/core/lifecycle/gracefulShutdown";
|
|
4634
4562
|
import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
|
|
4635
4563
|
import { isRlsTenancy } from "@getstrata/core/tenant/tenancyConfig";
|
|
4636
4564
|
import { migrate } from "../db/migrate.ts";
|
|
4637
4565
|
import { buildRoutes } from "../routes.ts";
|
|
4638
4566
|
import { loadConfig } from "./config.ts";
|
|
4639
|
-
import { getSql } from "./database.ts";
|
|
4567
|
+
import { closeDatabase, getSql, pingDatabase } from "./database.ts";
|
|
4640
4568
|
${ensureLine}import { starterProviders } from "./providers/index.ts";
|
|
4641
4569
|
|
|
4642
4570
|
export interface BootstrapOptions {
|
|
@@ -4644,98 +4572,71 @@ export interface BootstrapOptions {
|
|
|
4644
4572
|
}
|
|
4645
4573
|
|
|
4646
4574
|
export interface BootstrappedApp {
|
|
4647
|
-
context:
|
|
4575
|
+
context: InitializedAppContext;
|
|
4648
4576
|
routes: AppRouteMap;
|
|
4649
4577
|
config: ReturnType<typeof loadConfig>;
|
|
4650
4578
|
}
|
|
4651
4579
|
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
set<T>(key: string, value: T): T {
|
|
4656
|
-
this.values.set(key, value);
|
|
4657
|
-
return value;
|
|
4658
|
-
}
|
|
4659
|
-
|
|
4660
|
-
get<T>(key: string): T | undefined {
|
|
4661
|
-
return this.values.get(key) as T | undefined;
|
|
4662
|
-
}
|
|
4663
|
-
|
|
4664
|
-
require<T>(key: string): T {
|
|
4665
|
-
const value = this.get<T>(key);
|
|
4666
|
-
if (value === undefined) {
|
|
4667
|
-
throw new Error(\`Missing required config value "\${key}".\`);
|
|
4668
|
-
}
|
|
4669
|
-
return value;
|
|
4670
|
-
}
|
|
4671
|
-
|
|
4672
|
-
has(key: string): boolean {
|
|
4673
|
-
return this.values.has(key);
|
|
4674
|
-
}
|
|
4675
|
-
}
|
|
4676
|
-
|
|
4677
|
-
function createAppContext(): AppContext {
|
|
4678
|
-
const container = new ServiceContainer();
|
|
4679
|
-
const config = new AppConfigStore() as unknown as ConfigStore;
|
|
4680
|
-
const dependencies: MutableAppDependencies = { container };
|
|
4681
|
-
const context: ProviderContext = { container, config, dependencies };
|
|
4682
|
-
|
|
4683
|
-
runProviderPhase(starterProviders, "register", context);
|
|
4684
|
-
runProviderPhase(starterProviders, "boot", context);
|
|
4685
|
-
|
|
4580
|
+
async function createAppContext(): Promise<InitializedAppContext> {
|
|
4581
|
+
await ensureModulesLoaded();
|
|
4686
4582
|
const moduleProviders = discoverModules().flatMap((module) => module.providers ?? []);
|
|
4687
|
-
|
|
4688
|
-
runProviderPhase(moduleProviders, "boot", context);
|
|
4689
|
-
|
|
4690
|
-
assertAppDependenciesComplete(dependencies);
|
|
4691
|
-
|
|
4692
|
-
const appContext = { container, config, dependencies: dependencies as AppDependencies };
|
|
4693
|
-
setActiveApplicationContext(appContext as never);
|
|
4694
|
-
return appContext;
|
|
4583
|
+
return createProviderAppContext([...starterProviders, ...moduleProviders]);
|
|
4695
4584
|
}
|
|
4696
4585
|
|
|
4697
4586
|
export async function bootstrapApp(options: BootstrapOptions = {}): Promise<BootstrappedApp> {
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4587
|
+
let context: InitializedAppContext | undefined;
|
|
4588
|
+
try {
|
|
4589
|
+
const isProduction = isProductionEnv();
|
|
4590
|
+
// Dev boots migrate for convenience. Production must not mutate schema on
|
|
4591
|
+
// start, so run \`strata migrate\` as an explicit deploy step instead.
|
|
4592
|
+
const { migrate: runMigrate = !isProduction } = options;
|
|
4702
4593
|
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4594
|
+
if (isProduction) {
|
|
4595
|
+
assertProductionSecrets();
|
|
4596
|
+
}
|
|
4706
4597
|
|
|
4707
|
-
${needsEnsure(layers) ? `
|
|
4708
|
-
` : ""}
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4598
|
+
${needsEnsure(layers) ? ` await ensureAppDatabase();
|
|
4599
|
+
` : ""} const appConfig = loadConfig();
|
|
4600
|
+
getSql();
|
|
4601
|
+
if (!(await pingDatabase())) throw new Error("Database is not ready.");
|
|
4602
|
+
if (isRlsTenancy()) {
|
|
4603
|
+
await assertRlsLiveDatabaseRole();
|
|
4604
|
+
}
|
|
4605
|
+
configureModulesDirectory(join(import.meta.dir, "../modules"));
|
|
4606
|
+
await ensureModulesLoaded();
|
|
4607
|
+
context = await createAppContext();
|
|
4716
4608
|
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4609
|
+
if (runMigrate) {
|
|
4610
|
+
await migrate();
|
|
4611
|
+
}
|
|
4720
4612
|
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4613
|
+
const routes = mergeSpaRoutes(context.dependencies, {
|
|
4614
|
+
...createHealthRoutes(context.dependencies),
|
|
4615
|
+
...buildRoutes(context.dependencies),${metricsSpread}
|
|
4616
|
+
}, {
|
|
4617
|
+
distDirectory: join(import.meta.dir, "../../frontend/dist"),
|
|
4618
|
+
});
|
|
4727
4619
|
|
|
4728
|
-
|
|
4620
|
+
return { context, routes, config: appConfig };
|
|
4621
|
+
} catch (error) {
|
|
4622
|
+
const failures: unknown[] = [];
|
|
4623
|
+
for (const cleanup of [() => context?.dispose(), closeDatabase]) {
|
|
4624
|
+
try { await cleanup(); } catch (failure) { failures.push(failure); }
|
|
4625
|
+
}
|
|
4626
|
+
if (failures.length) throw new AggregateError([error, ...failures], "Application startup and cleanup failed.", { cause: error });
|
|
4627
|
+
throw error;
|
|
4628
|
+
}
|
|
4729
4629
|
}
|
|
4730
4630
|
|
|
4731
4631
|
export async function createApp(options: BootstrapOptions = {}) {
|
|
4732
4632
|
return bootstrapApp({ migrate: false, ...options });
|
|
4733
4633
|
}
|
|
4734
4634
|
|
|
4735
|
-
export function createAppServer(routes: AppRouteMap, port = 0) {
|
|
4635
|
+
export function createAppServer(routes: AppRouteMap, port = 0, lifecycle?: LifecycleCoordinator) {
|
|
4736
4636
|
return createWebServer({
|
|
4737
4637
|
port,
|
|
4738
4638
|
publicDir: "./public",
|
|
4639
|
+
lifecycle,
|
|
4739
4640
|
routes,
|
|
4740
4641
|
});
|
|
4741
4642
|
}
|
|
@@ -4791,9 +4692,20 @@ function renderCliQueueWorkTs() {
|
|
|
4791
4692
|
const { bootstrapApp } = await import("../bootstrap/createApp.ts");
|
|
4792
4693
|
const { closeDatabase } = await import("../bootstrap/database.ts");
|
|
4793
4694
|
|
|
4695
|
+
let app: Awaited<ReturnType<typeof bootstrapApp>> | undefined;
|
|
4794
4696
|
await runQueueWorkerCommand({
|
|
4795
|
-
boot: () =>
|
|
4796
|
-
|
|
4697
|
+
boot: async () => {
|
|
4698
|
+
app = await bootstrapApp({ migrate: false });
|
|
4699
|
+
},
|
|
4700
|
+
drain: () => app?.context.drain(),
|
|
4701
|
+
flush: () => app?.context.flush(),
|
|
4702
|
+
close: async () => {
|
|
4703
|
+
try {
|
|
4704
|
+
await app?.context.dispose();
|
|
4705
|
+
} finally {
|
|
4706
|
+
await closeDatabase();
|
|
4707
|
+
}
|
|
4708
|
+
},
|
|
4797
4709
|
});
|
|
4798
4710
|
}
|
|
4799
4711
|
|
|
@@ -4851,11 +4763,15 @@ export { openapiCheckCommand, openapiGenerateCommand, openapiValidateCommand };
|
|
|
4851
4763
|
function renderCliScheduleRunTs() {
|
|
4852
4764
|
return `async function scheduleRunCommand(): Promise<void> {
|
|
4853
4765
|
const { createScheduleRunCommand } = await import("@getstrata/cli/schedule");
|
|
4766
|
+
const { bootstrapApp } = await import("../bootstrap/createApp.ts");
|
|
4767
|
+
const { closeDatabase } = await import("../bootstrap/database.ts");
|
|
4768
|
+
let app: Awaited<ReturnType<typeof bootstrapApp>> | undefined;
|
|
4854
4769
|
await createScheduleRunCommand(async () => {
|
|
4855
|
-
|
|
4856
|
-
await bootstrapApp({ migrate: false });
|
|
4770
|
+
app = await bootstrapApp({ migrate: false });
|
|
4857
4771
|
await import("../bootstrap/schedule.ts");
|
|
4858
|
-
}
|
|
4772
|
+
}, async () => {
|
|
4773
|
+
try { await app?.context.dispose(); } finally { await closeDatabase(); }
|
|
4774
|
+
}, { drain: () => app?.context.drain(), flush: () => app?.context.flush() })();
|
|
4859
4775
|
}
|
|
4860
4776
|
|
|
4861
4777
|
export { scheduleRunCommand };
|
|
@@ -5237,6 +5153,7 @@ function renderScimModule(layers) {
|
|
|
5237
5153
|
return null;
|
|
5238
5154
|
}
|
|
5239
5155
|
return `import { randomBytes } from "node:crypto";
|
|
5156
|
+
import type { HttpKernel } from "@getstrata/bootstrap/httpKernel";
|
|
5240
5157
|
import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
5241
5158
|
import { routeParams } from "@getstrata/bootstrap/web/routing";
|
|
5242
5159
|
import { createScimAuthMiddleware } from "@getstrata/core/auth/scimAuthMiddleware";
|
|
@@ -5334,12 +5251,12 @@ function readName(body: Record<string, unknown>, fallback: string): string {
|
|
|
5334
5251
|
return fallback;
|
|
5335
5252
|
}
|
|
5336
5253
|
|
|
5337
|
-
function wrapScim(handler: (request: Request) => Promise<Response>) {
|
|
5338
|
-
const throttle = createScimThrottleMiddleware({
|
|
5254
|
+
function wrapScim(kernel: HttpKernel, handler: (request: Request) => Promise<Response>) {
|
|
5255
|
+
const throttle = kernel.ownThrottle(createScimThrottleMiddleware({
|
|
5339
5256
|
redisUrl: process.env.REDIS_URL,
|
|
5340
5257
|
maxAttempts: 120,
|
|
5341
5258
|
decaySeconds: 60,
|
|
5342
|
-
});
|
|
5259
|
+
}));
|
|
5343
5260
|
return withMiddleware(throttle, createScimAuthMiddleware())(
|
|
5344
5261
|
withErrorHandling(async (request) => {
|
|
5345
5262
|
if (!scimEnabled()) {
|
|
@@ -5358,7 +5275,7 @@ const scimModule: AppModule = {
|
|
|
5358
5275
|
"/scim/v2/ServiceProviderConfig": {
|
|
5359
5276
|
GET: kernel.wrap(
|
|
5360
5277
|
"api",
|
|
5361
|
-
wrapScim(async () =>
|
|
5278
|
+
wrapScim(kernel, async () =>
|
|
5362
5279
|
scimJson({
|
|
5363
5280
|
schemas: [CONFIG_SCHEMA],
|
|
5364
5281
|
patch: { supported: true },
|
|
@@ -5383,7 +5300,7 @@ const scimModule: AppModule = {
|
|
|
5383
5300
|
"/scim/v2/Users": {
|
|
5384
5301
|
GET: kernel.wrap(
|
|
5385
5302
|
"api",
|
|
5386
|
-
wrapScim(async (request) => {
|
|
5303
|
+
wrapScim(kernel, async (request) => {
|
|
5387
5304
|
const url = new URL(request.url);
|
|
5388
5305
|
const filter = url.searchParams.get("filter") ?? "";
|
|
5389
5306
|
const match = /userName\\s+eq\\s+"([^"]+)"/i.exec(filter);
|
|
@@ -5415,7 +5332,7 @@ const scimModule: AppModule = {
|
|
|
5415
5332
|
),
|
|
5416
5333
|
POST: kernel.wrap(
|
|
5417
5334
|
"api",
|
|
5418
|
-
wrapScim(async (request) => {
|
|
5335
|
+
wrapScim(kernel, async (request) => {
|
|
5419
5336
|
const body = (await request.json()) as Record<string, unknown>;
|
|
5420
5337
|
const email = readUserName(body);
|
|
5421
5338
|
const name = readName(body, email.split("@")[0] ?? "User");
|
|
@@ -5438,7 +5355,7 @@ const scimModule: AppModule = {
|
|
|
5438
5355
|
"/scim/v2/Users/:id": {
|
|
5439
5356
|
GET: kernel.wrap(
|
|
5440
5357
|
"api",
|
|
5441
|
-
wrapScim(async (request) => {
|
|
5358
|
+
wrapScim(kernel, async (request) => {
|
|
5442
5359
|
const id = readScimUserId(request);
|
|
5443
5360
|
if (id instanceof Response) {
|
|
5444
5361
|
return id;
|
|
@@ -5452,7 +5369,7 @@ const scimModule: AppModule = {
|
|
|
5452
5369
|
),
|
|
5453
5370
|
PUT: kernel.wrap(
|
|
5454
5371
|
"api",
|
|
5455
|
-
wrapScim(async (request) => {
|
|
5372
|
+
wrapScim(kernel, async (request) => {
|
|
5456
5373
|
const id = readScimUserId(request);
|
|
5457
5374
|
if (id instanceof Response) {
|
|
5458
5375
|
return id;
|
|
@@ -5473,7 +5390,7 @@ const scimModule: AppModule = {
|
|
|
5473
5390
|
),
|
|
5474
5391
|
PATCH: kernel.wrap(
|
|
5475
5392
|
"api",
|
|
5476
|
-
wrapScim(async (request) => {
|
|
5393
|
+
wrapScim(kernel, async (request) => {
|
|
5477
5394
|
const id = readScimUserId(request);
|
|
5478
5395
|
if (id instanceof Response) {
|
|
5479
5396
|
return id;
|
|
@@ -5514,7 +5431,7 @@ const scimModule: AppModule = {
|
|
|
5514
5431
|
),
|
|
5515
5432
|
DELETE: kernel.wrap(
|
|
5516
5433
|
"api",
|
|
5517
|
-
wrapScim(async (request) => {
|
|
5434
|
+
wrapScim(kernel, async (request) => {
|
|
5518
5435
|
const id = readScimUserId(request);
|
|
5519
5436
|
if (id instanceof Response) {
|
|
5520
5437
|
return id;
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
"check": "tsc --noEmit"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@getstrata/bootstrap": "^2.0
|
|
16
|
-
"@getstrata/cli": "^2.0
|
|
17
|
-
"@getstrata/core": "^2.0
|
|
15
|
+
"@getstrata/bootstrap": "^2.2.0",
|
|
16
|
+
"@getstrata/cli": "^2.2.0",
|
|
17
|
+
"@getstrata/core": "^2.2.0",
|
|
18
18
|
"eta": "^4.6.0"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
@@ -5,13 +5,13 @@ import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
|
5
5
|
|
|
6
6
|
const cacheProvider: ServiceProvider = {
|
|
7
7
|
name: "starter.cache",
|
|
8
|
-
register({ container, config, dependencies }) {
|
|
8
|
+
register({ container, config, dependencies, onCleanup }) {
|
|
9
9
|
container.singleton(CORE_CACHE_TOKEN, () => {
|
|
10
10
|
const ttlMs = config.get<number>("cache.ttlMs") ?? 3_600_000;
|
|
11
11
|
const maxEntries = config.get<number>("cache.maxEntries") ?? 100;
|
|
12
12
|
const driver = config.get<"array" | "redis">("cache.driver") ?? "array";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const cache = new CacheRepository(
|
|
15
15
|
createCacheStore({
|
|
16
16
|
driver,
|
|
17
17
|
ttlMs,
|
|
@@ -19,6 +19,8 @@ const cacheProvider: ServiceProvider = {
|
|
|
19
19
|
redisUrl: process.env.REDIS_URL,
|
|
20
20
|
}),
|
|
21
21
|
);
|
|
22
|
+
onCleanup(() => cache.close());
|
|
23
|
+
return cache;
|
|
22
24
|
});
|
|
23
25
|
|
|
24
26
|
Reflect.set(dependencies, "cache", container.resolve(CORE_CACHE_TOKEN));
|
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
import "./preload.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
installGracefulShutdownSignals,
|
|
4
|
+
LifecycleCoordinator,
|
|
5
|
+
} from "@getstrata/core/lifecycle/gracefulShutdown";
|
|
3
6
|
import { bootstrapApp, createAppServer } from "./createApp.ts";
|
|
4
|
-
import { closeDatabase
|
|
7
|
+
import { closeDatabase } from "./database.ts";
|
|
5
8
|
|
|
6
|
-
await
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async function shutdown() {
|
|
19
|
-
await closeDatabase();
|
|
20
|
-
server.stop();
|
|
21
|
-
process.exit(0);
|
|
9
|
+
const { routes, config, context } = await bootstrapApp();
|
|
10
|
+
const lifecycle = new LifecycleCoordinator();
|
|
11
|
+
let server: ReturnType<typeof createAppServer>;
|
|
12
|
+
try {
|
|
13
|
+
server = createAppServer(routes, config.port, lifecycle);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
try {
|
|
16
|
+
await context.dispose();
|
|
17
|
+
} finally {
|
|
18
|
+
await closeDatabase();
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
22
21
|
}
|
|
22
|
+
console.log(`Listening on http://localhost:${server.port} (APP_URL ${config.appUrl})`);
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
lifecycle.register("providers:drain", () => context.drain(), "drain");
|
|
25
|
+
lifecycle.register("providers:flush", () => context.flush(), "flush");
|
|
26
|
+
lifecycle.register("providers:close", () => context.dispose(), "close");
|
|
27
|
+
lifecycle.register("database", closeDatabase, "close");
|
|
28
|
+
installGracefulShutdownSignals(undefined, lifecycle);
|