@getstrata/starter 2.1.0 → 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.
|
|
3195
|
-
"@getstrata/cli": "^2.
|
|
3196
|
-
"@getstrata/core": "^2.
|
|
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,15 +4048,13 @@ const notesTable = defineTable<NoteRecord, "id">({
|
|
|
4047
4048
|
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
4048
4049
|
});
|
|
4049
4050
|
|
|
4050
|
-
class Note extends
|
|
4051
|
+
class Note extends defineModel(notesTable) {
|
|
4051
4052
|
static $fillable = ${fillable} as const;
|
|
4052
4053
|
// created_at uses the table default. Sending a JS Date from $timestamps
|
|
4053
4054
|
// is rejected by SQLite bindings.
|
|
4054
4055
|
static $timestamps = false;
|
|
4055
4056
|
}
|
|
4056
4057
|
|
|
4057
|
-
registerModelRepository(Note, new BaseRepository(notesTable));
|
|
4058
|
-
|
|
4059
4058
|
export type { NoteRecord };
|
|
4060
4059
|
export { Note };
|
|
4061
4060
|
`;
|
|
@@ -4092,8 +4091,7 @@ function renderUserModel(layers) {
|
|
|
4092
4091
|
const fillableLiteral = `[${fillable.map((column) => `"${column}"`).join(", ")}]`;
|
|
4093
4092
|
const hiddenLiteral = `[${hidden.map((column) => `"${column}"`).join(", ")}]`;
|
|
4094
4093
|
const columnsLiteral = columns.map((column) => `"${column}"`).join(", ");
|
|
4095
|
-
return `import {
|
|
4096
|
-
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
4094
|
+
return `import { defineModel } from "@getstrata/core/database/model";
|
|
4097
4095
|
import { defineTable } from "@getstrata/core/database/table";
|
|
4098
4096
|
|
|
4099
4097
|
interface UserRecord {
|
|
@@ -4114,7 +4112,7 @@ const usersTable = defineTable<UserRecord, "id">({
|
|
|
4114
4112
|
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
4115
4113
|
});
|
|
4116
4114
|
|
|
4117
|
-
class User extends
|
|
4115
|
+
class User extends defineModel(usersTable) {
|
|
4118
4116
|
static $fillable = ${fillableLiteral} as const;
|
|
4119
4117
|
static $hidden = ${hiddenLiteral} as const;
|
|
4120
4118
|
// created_at uses the table default. Sending a JS Date from $timestamps
|
|
@@ -4122,15 +4120,12 @@ class User extends Model<UserRecord, "id"> {
|
|
|
4122
4120
|
static $timestamps = false;
|
|
4123
4121
|
}
|
|
4124
4122
|
|
|
4125
|
-
registerModelRepository(User, new BaseRepository(usersTable));
|
|
4126
|
-
|
|
4127
4123
|
export type { UserRecord };
|
|
4128
4124
|
export { User };
|
|
4129
4125
|
`;
|
|
4130
4126
|
}
|
|
4131
4127
|
function renderApiTokenModel() {
|
|
4132
|
-
return `import {
|
|
4133
|
-
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
4128
|
+
return `import { defineModel } from "@getstrata/core/database/model";
|
|
4134
4129
|
import { defineTable } from "@getstrata/core/database/table";
|
|
4135
4130
|
|
|
4136
4131
|
interface ApiTokenRecord {
|
|
@@ -4151,22 +4146,19 @@ const apiTokensTable = defineTable<ApiTokenRecord, "id">({
|
|
|
4151
4146
|
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
4152
4147
|
});
|
|
4153
4148
|
|
|
4154
|
-
class ApiToken extends
|
|
4149
|
+
class ApiToken extends defineModel(apiTokensTable) {
|
|
4155
4150
|
static $fillable = ["user_id", "name", "token_hash", "abilities", "expires_at", "last_used_at"] as const;
|
|
4156
4151
|
// created_at uses the table default. Sending a JS Date from $timestamps
|
|
4157
4152
|
// is rejected by SQLite bindings.
|
|
4158
4153
|
static $timestamps = false;
|
|
4159
4154
|
}
|
|
4160
4155
|
|
|
4161
|
-
registerModelRepository(ApiToken, new BaseRepository(apiTokensTable));
|
|
4162
|
-
|
|
4163
4156
|
export type { ApiTokenRecord };
|
|
4164
4157
|
export { ApiToken };
|
|
4165
4158
|
`;
|
|
4166
4159
|
}
|
|
4167
4160
|
function renderAuthOneTimeTokenModel() {
|
|
4168
|
-
return `import {
|
|
4169
|
-
import { Model, registerModelRepository } from "@getstrata/core/database/model";
|
|
4161
|
+
return `import { defineModel } from "@getstrata/core/database/model";
|
|
4170
4162
|
import { defineTable } from "@getstrata/core/database/table";
|
|
4171
4163
|
|
|
4172
4164
|
interface AuthOneTimeTokenRecord {
|
|
@@ -4185,37 +4177,20 @@ const authOneTimeTokensTable = defineTable<AuthOneTimeTokenRecord, "id">({
|
|
|
4185
4177
|
defaultOrderBy: { column: "id", direction: "ASC" },
|
|
4186
4178
|
});
|
|
4187
4179
|
|
|
4188
|
-
class AuthOneTimeToken extends
|
|
4180
|
+
class AuthOneTimeToken extends defineModel(authOneTimeTokensTable) {
|
|
4189
4181
|
static $fillable = ["purpose", "user_id", "token_hash", "expires_at", "consumed_at"] as const;
|
|
4190
4182
|
static $timestamps = false;
|
|
4191
4183
|
}
|
|
4192
4184
|
|
|
4193
|
-
registerModelRepository(AuthOneTimeToken, new BaseRepository(authOneTimeTokensTable));
|
|
4194
|
-
|
|
4195
4185
|
export type { AuthOneTimeTokenRecord };
|
|
4196
4186
|
export { AuthOneTimeToken };
|
|
4197
4187
|
`;
|
|
4198
4188
|
}
|
|
4199
|
-
function renderModelsRegister(
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
];
|
|
4205
|
-
if (authNeedsUsers(layers.auth)) {
|
|
4206
|
-
lines.push('import "./User.ts";');
|
|
4207
|
-
lines.push('import "./AuthOneTimeToken.ts";');
|
|
4208
|
-
}
|
|
4209
|
-
if (authUsesToken(layers.auth)) {
|
|
4210
|
-
lines.push('import "./ApiToken.ts";');
|
|
4211
|
-
}
|
|
4212
|
-
lines.push("");
|
|
4213
|
-
lines.push("// Optional alias when a relation uses belongsTo('CategoryAlias'):");
|
|
4214
|
-
lines.push('// import { registerModelClass } from "@getstrata/core/database/model";');
|
|
4215
|
-
lines.push('// registerModelClass("CategoryAlias", Category);');
|
|
4216
|
-
lines.push("");
|
|
4217
|
-
return `${lines.join(`
|
|
4218
|
-
`)}
|
|
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);
|
|
4219
4194
|
`;
|
|
4220
4195
|
}
|
|
4221
4196
|
function dropTables(layers) {
|
|
@@ -4415,17 +4390,15 @@ import {
|
|
|
4415
4390
|
|
|
4416
4391
|
const queueProvider: ServiceProvider = {
|
|
4417
4392
|
name: "starter.queue",
|
|
4418
|
-
register({ container }) {
|
|
4393
|
+
async register({ container, onCleanup }) {
|
|
4419
4394
|
const driver = (process.env.QUEUE_DRIVER ?? "sync") as "sync" | "async" | "redis";
|
|
4420
4395
|
const failedJobs = createFailedJobService();
|
|
4421
4396
|
container.set(FAILED_JOB_SERVICE_TOKEN, failedJobs);
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
}),
|
|
4428
|
-
);
|
|
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);
|
|
4429
4402
|
},
|
|
4430
4403
|
};
|
|
4431
4404
|
|
|
@@ -4529,7 +4502,8 @@ export default policyProvider;
|
|
|
4529
4502
|
`;
|
|
4530
4503
|
}
|
|
4531
4504
|
function renderProvidersIndex() {
|
|
4532
|
-
return `import {
|
|
4505
|
+
return `import { tracingProvider } from "@getstrata/bootstrap";
|
|
4506
|
+
import { discoverListeners } from "@getstrata/bootstrap/discoverListeners";
|
|
4533
4507
|
import { registerInvalidateCacheOnModelWriteListeners } from "@getstrata/bootstrap/listeners/invalidateCacheOnModelWrite";
|
|
4534
4508
|
import type { ServiceProvider } from "@getstrata/core/contracts/di";
|
|
4535
4509
|
import authProvider from "./auth.ts";
|
|
@@ -4539,32 +4513,20 @@ import policyProvider from "./policy.ts";
|
|
|
4539
4513
|
import queueProvider from "./queue.ts";
|
|
4540
4514
|
import storageProvider from "./storage.ts";
|
|
4541
4515
|
|
|
4542
|
-
const registeredListenerGroups = new Set<string>();
|
|
4543
|
-
|
|
4544
|
-
function registerListenerGroup(name: string, register: () => void): void {
|
|
4545
|
-
if (registeredListenerGroups.has(name)) {
|
|
4546
|
-
return;
|
|
4547
|
-
}
|
|
4548
|
-
|
|
4549
|
-
registeredListenerGroups.add(name);
|
|
4550
|
-
register();
|
|
4551
|
-
}
|
|
4552
|
-
|
|
4553
4516
|
const listenersProvider: ServiceProvider = {
|
|
4554
4517
|
name: "starter.listeners",
|
|
4555
|
-
boot() {
|
|
4556
|
-
|
|
4557
|
-
registerInvalidateCacheOnModelWriteListeners();
|
|
4558
|
-
});
|
|
4518
|
+
async boot({ onCleanup }) {
|
|
4519
|
+
onCleanup(registerInvalidateCacheOnModelWriteListeners());
|
|
4559
4520
|
|
|
4560
|
-
for (const registerListener of discoverListeners()) {
|
|
4561
|
-
registerListener();
|
|
4521
|
+
for (const registerListener of await discoverListeners()) {
|
|
4522
|
+
await registerListener();
|
|
4562
4523
|
}
|
|
4563
4524
|
},
|
|
4564
4525
|
};
|
|
4565
4526
|
|
|
4566
4527
|
const starterProviders: ServiceProvider[] = [
|
|
4567
4528
|
configProvider,
|
|
4529
|
+
tracingProvider,
|
|
4568
4530
|
cacheProvider,
|
|
4569
4531
|
storageProvider,
|
|
4570
4532
|
queueProvider,
|
|
@@ -4585,16 +4547,8 @@ function renderCreateAppTs(layers) {
|
|
|
4585
4547
|
...createMetricsRoutes(),` : "";
|
|
4586
4548
|
return `import { join } from "node:path";
|
|
4587
4549
|
import "./preload.ts";
|
|
4588
|
-
import {
|
|
4589
|
-
import {
|
|
4590
|
-
type AppContext,
|
|
4591
|
-
type AppRouteMap,
|
|
4592
|
-
assertAppDependenciesComplete,
|
|
4593
|
-
ConfigStore,
|
|
4594
|
-
type MutableAppDependencies,
|
|
4595
|
-
type ProviderContext,
|
|
4596
|
-
ServiceContainer,
|
|
4597
|
-
} from "@getstrata/bootstrap/contracts";
|
|
4550
|
+
import { createAppContext as createProviderAppContext, type InitializedAppContext } from "@getstrata/bootstrap/context";
|
|
4551
|
+
import type { AppRouteMap } from "@getstrata/bootstrap/contracts";
|
|
4598
4552
|
import { mergeSpaRoutes } from "@getstrata/bootstrap/createSpaRoutes";
|
|
4599
4553
|
import {
|
|
4600
4554
|
configureModulesDirectory,
|
|
@@ -4604,13 +4558,13 @@ import {
|
|
|
4604
4558
|
import { createHealthRoutes } from "@getstrata/bootstrap/health";
|
|
4605
4559
|
${metricsImport}import { assertProductionSecrets, assertRlsLiveDatabaseRole } from "@getstrata/bootstrap/secretsGuard";
|
|
4606
4560
|
import { createWebServer } from "@getstrata/bootstrap/web/server";
|
|
4607
|
-
import {
|
|
4561
|
+
import type { LifecycleCoordinator } from "@getstrata/core/lifecycle/gracefulShutdown";
|
|
4608
4562
|
import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
|
|
4609
4563
|
import { isRlsTenancy } from "@getstrata/core/tenant/tenancyConfig";
|
|
4610
4564
|
import { migrate } from "../db/migrate.ts";
|
|
4611
4565
|
import { buildRoutes } from "../routes.ts";
|
|
4612
4566
|
import { loadConfig } from "./config.ts";
|
|
4613
|
-
import { getSql } from "./database.ts";
|
|
4567
|
+
import { closeDatabase, getSql, pingDatabase } from "./database.ts";
|
|
4614
4568
|
${ensureLine}import { starterProviders } from "./providers/index.ts";
|
|
4615
4569
|
|
|
4616
4570
|
export interface BootstrapOptions {
|
|
@@ -4618,73 +4572,71 @@ export interface BootstrapOptions {
|
|
|
4618
4572
|
}
|
|
4619
4573
|
|
|
4620
4574
|
export interface BootstrappedApp {
|
|
4621
|
-
context:
|
|
4575
|
+
context: InitializedAppContext;
|
|
4622
4576
|
routes: AppRouteMap;
|
|
4623
4577
|
config: ReturnType<typeof loadConfig>;
|
|
4624
4578
|
}
|
|
4625
4579
|
|
|
4626
|
-
function createAppContext():
|
|
4627
|
-
|
|
4628
|
-
const config = new ConfigStore();
|
|
4629
|
-
const dependencies: MutableAppDependencies = { container };
|
|
4630
|
-
const context: ProviderContext = { container, config, dependencies };
|
|
4631
|
-
|
|
4632
|
-
runProviderPhase(starterProviders, "register", context);
|
|
4633
|
-
runProviderPhase(starterProviders, "boot", context);
|
|
4634
|
-
|
|
4580
|
+
async function createAppContext(): Promise<InitializedAppContext> {
|
|
4581
|
+
await ensureModulesLoaded();
|
|
4635
4582
|
const moduleProviders = discoverModules().flatMap((module) => module.providers ?? []);
|
|
4636
|
-
|
|
4637
|
-
runProviderPhase(moduleProviders, "boot", context);
|
|
4638
|
-
|
|
4639
|
-
assertAppDependenciesComplete(dependencies);
|
|
4640
|
-
|
|
4641
|
-
const appContext: AppContext = { container, config, dependencies };
|
|
4642
|
-
setActiveApplicationContext(appContext);
|
|
4643
|
-
return appContext;
|
|
4583
|
+
return createProviderAppContext([...starterProviders, ...moduleProviders]);
|
|
4644
4584
|
}
|
|
4645
4585
|
|
|
4646
4586
|
export async function bootstrapApp(options: BootstrapOptions = {}): Promise<BootstrappedApp> {
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
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;
|
|
4651
4593
|
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4594
|
+
if (isProduction) {
|
|
4595
|
+
assertProductionSecrets();
|
|
4596
|
+
}
|
|
4655
4597
|
|
|
4656
|
-
${needsEnsure(layers) ? `
|
|
4657
|
-
` : ""}
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
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();
|
|
4665
4608
|
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4609
|
+
if (runMigrate) {
|
|
4610
|
+
await migrate();
|
|
4611
|
+
}
|
|
4669
4612
|
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
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
|
+
});
|
|
4676
4619
|
|
|
4677
|
-
|
|
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
|
+
}
|
|
4678
4629
|
}
|
|
4679
4630
|
|
|
4680
4631
|
export async function createApp(options: BootstrapOptions = {}) {
|
|
4681
4632
|
return bootstrapApp({ migrate: false, ...options });
|
|
4682
4633
|
}
|
|
4683
4634
|
|
|
4684
|
-
export function createAppServer(routes: AppRouteMap, port = 0) {
|
|
4635
|
+
export function createAppServer(routes: AppRouteMap, port = 0, lifecycle?: LifecycleCoordinator) {
|
|
4685
4636
|
return createWebServer({
|
|
4686
4637
|
port,
|
|
4687
4638
|
publicDir: "./public",
|
|
4639
|
+
lifecycle,
|
|
4688
4640
|
routes,
|
|
4689
4641
|
});
|
|
4690
4642
|
}
|
|
@@ -4740,9 +4692,20 @@ function renderCliQueueWorkTs() {
|
|
|
4740
4692
|
const { bootstrapApp } = await import("../bootstrap/createApp.ts");
|
|
4741
4693
|
const { closeDatabase } = await import("../bootstrap/database.ts");
|
|
4742
4694
|
|
|
4695
|
+
let app: Awaited<ReturnType<typeof bootstrapApp>> | undefined;
|
|
4743
4696
|
await runQueueWorkerCommand({
|
|
4744
|
-
boot: () =>
|
|
4745
|
-
|
|
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
|
+
},
|
|
4746
4709
|
});
|
|
4747
4710
|
}
|
|
4748
4711
|
|
|
@@ -4800,11 +4763,15 @@ export { openapiCheckCommand, openapiGenerateCommand, openapiValidateCommand };
|
|
|
4800
4763
|
function renderCliScheduleRunTs() {
|
|
4801
4764
|
return `async function scheduleRunCommand(): Promise<void> {
|
|
4802
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;
|
|
4803
4769
|
await createScheduleRunCommand(async () => {
|
|
4804
|
-
|
|
4805
|
-
await bootstrapApp({ migrate: false });
|
|
4770
|
+
app = await bootstrapApp({ migrate: false });
|
|
4806
4771
|
await import("../bootstrap/schedule.ts");
|
|
4807
|
-
}
|
|
4772
|
+
}, async () => {
|
|
4773
|
+
try { await app?.context.dispose(); } finally { await closeDatabase(); }
|
|
4774
|
+
}, { drain: () => app?.context.drain(), flush: () => app?.context.flush() })();
|
|
4808
4775
|
}
|
|
4809
4776
|
|
|
4810
4777
|
export { scheduleRunCommand };
|
|
@@ -5186,6 +5153,7 @@ function renderScimModule(layers) {
|
|
|
5186
5153
|
return null;
|
|
5187
5154
|
}
|
|
5188
5155
|
return `import { randomBytes } from "node:crypto";
|
|
5156
|
+
import type { HttpKernel } from "@getstrata/bootstrap/httpKernel";
|
|
5189
5157
|
import type { AppModule } from "@getstrata/bootstrap/contracts";
|
|
5190
5158
|
import { routeParams } from "@getstrata/bootstrap/web/routing";
|
|
5191
5159
|
import { createScimAuthMiddleware } from "@getstrata/core/auth/scimAuthMiddleware";
|
|
@@ -5283,12 +5251,12 @@ function readName(body: Record<string, unknown>, fallback: string): string {
|
|
|
5283
5251
|
return fallback;
|
|
5284
5252
|
}
|
|
5285
5253
|
|
|
5286
|
-
function wrapScim(handler: (request: Request) => Promise<Response>) {
|
|
5287
|
-
const throttle = createScimThrottleMiddleware({
|
|
5254
|
+
function wrapScim(kernel: HttpKernel, handler: (request: Request) => Promise<Response>) {
|
|
5255
|
+
const throttle = kernel.ownThrottle(createScimThrottleMiddleware({
|
|
5288
5256
|
redisUrl: process.env.REDIS_URL,
|
|
5289
5257
|
maxAttempts: 120,
|
|
5290
5258
|
decaySeconds: 60,
|
|
5291
|
-
});
|
|
5259
|
+
}));
|
|
5292
5260
|
return withMiddleware(throttle, createScimAuthMiddleware())(
|
|
5293
5261
|
withErrorHandling(async (request) => {
|
|
5294
5262
|
if (!scimEnabled()) {
|
|
@@ -5307,7 +5275,7 @@ const scimModule: AppModule = {
|
|
|
5307
5275
|
"/scim/v2/ServiceProviderConfig": {
|
|
5308
5276
|
GET: kernel.wrap(
|
|
5309
5277
|
"api",
|
|
5310
|
-
wrapScim(async () =>
|
|
5278
|
+
wrapScim(kernel, async () =>
|
|
5311
5279
|
scimJson({
|
|
5312
5280
|
schemas: [CONFIG_SCHEMA],
|
|
5313
5281
|
patch: { supported: true },
|
|
@@ -5332,7 +5300,7 @@ const scimModule: AppModule = {
|
|
|
5332
5300
|
"/scim/v2/Users": {
|
|
5333
5301
|
GET: kernel.wrap(
|
|
5334
5302
|
"api",
|
|
5335
|
-
wrapScim(async (request) => {
|
|
5303
|
+
wrapScim(kernel, async (request) => {
|
|
5336
5304
|
const url = new URL(request.url);
|
|
5337
5305
|
const filter = url.searchParams.get("filter") ?? "";
|
|
5338
5306
|
const match = /userName\\s+eq\\s+"([^"]+)"/i.exec(filter);
|
|
@@ -5364,7 +5332,7 @@ const scimModule: AppModule = {
|
|
|
5364
5332
|
),
|
|
5365
5333
|
POST: kernel.wrap(
|
|
5366
5334
|
"api",
|
|
5367
|
-
wrapScim(async (request) => {
|
|
5335
|
+
wrapScim(kernel, async (request) => {
|
|
5368
5336
|
const body = (await request.json()) as Record<string, unknown>;
|
|
5369
5337
|
const email = readUserName(body);
|
|
5370
5338
|
const name = readName(body, email.split("@")[0] ?? "User");
|
|
@@ -5387,7 +5355,7 @@ const scimModule: AppModule = {
|
|
|
5387
5355
|
"/scim/v2/Users/:id": {
|
|
5388
5356
|
GET: kernel.wrap(
|
|
5389
5357
|
"api",
|
|
5390
|
-
wrapScim(async (request) => {
|
|
5358
|
+
wrapScim(kernel, async (request) => {
|
|
5391
5359
|
const id = readScimUserId(request);
|
|
5392
5360
|
if (id instanceof Response) {
|
|
5393
5361
|
return id;
|
|
@@ -5401,7 +5369,7 @@ const scimModule: AppModule = {
|
|
|
5401
5369
|
),
|
|
5402
5370
|
PUT: kernel.wrap(
|
|
5403
5371
|
"api",
|
|
5404
|
-
wrapScim(async (request) => {
|
|
5372
|
+
wrapScim(kernel, async (request) => {
|
|
5405
5373
|
const id = readScimUserId(request);
|
|
5406
5374
|
if (id instanceof Response) {
|
|
5407
5375
|
return id;
|
|
@@ -5422,7 +5390,7 @@ const scimModule: AppModule = {
|
|
|
5422
5390
|
),
|
|
5423
5391
|
PATCH: kernel.wrap(
|
|
5424
5392
|
"api",
|
|
5425
|
-
wrapScim(async (request) => {
|
|
5393
|
+
wrapScim(kernel, async (request) => {
|
|
5426
5394
|
const id = readScimUserId(request);
|
|
5427
5395
|
if (id instanceof Response) {
|
|
5428
5396
|
return id;
|
|
@@ -5463,7 +5431,7 @@ const scimModule: AppModule = {
|
|
|
5463
5431
|
),
|
|
5464
5432
|
DELETE: kernel.wrap(
|
|
5465
5433
|
"api",
|
|
5466
|
-
wrapScim(async (request) => {
|
|
5434
|
+
wrapScim(kernel, async (request) => {
|
|
5467
5435
|
const id = readScimUserId(request);
|
|
5468
5436
|
if (id instanceof Response) {
|
|
5469
5437
|
return id;
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
"check": "tsc --noEmit"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@getstrata/bootstrap": "^2.
|
|
16
|
-
"@getstrata/cli": "^2.
|
|
17
|
-
"@getstrata/core": "^2.
|
|
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);
|