@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.9",
3195
- "@getstrata/cli": "^2.0.9",
3196
- "@getstrata/core": "^2.0.9"
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, plan, region) VALUES (${ph ? "$1, $2, $3" : "?, ?, ?"})",
3957
- ["default", "free", "eu"],
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 { BaseRepository } from "@getstrata/core/database/baseRepository";
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 NoteRepository extends BaseRepository<NoteRecord, "id"> {
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 { BaseRepository } from "@getstrata/core/database/baseRepository";
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 UserRepository extends BaseRepository<UserRecord, "id"> {
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 { BaseRepository } from "@getstrata/core/database/baseRepository";
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 ApiTokenRepository extends BaseRepository<ApiTokenRecord, "id"> {
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 { BaseRepository } from "@getstrata/core/database/baseRepository";
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 AuthOneTimeTokenRepository extends BaseRepository<AuthOneTimeTokenRecord, "id"> {
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(layers) {
4224
- const lines = [
4225
- "// Side-effect imports run registerModelRepository() in each model file.",
4226
- "// registerModelClass() is only for a string alias that is neither constructor.name nor $morphClass.",
4227
- 'import { registerModelClass } from "@getstrata/core/database/model";',
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
- container.set(
4448
- CORE_QUEUE_TOKEN,
4449
- createAppQueue(driver, process.env.REDIS_URL, failedJobs, () => {
4450
- registerDefaultJobs();
4451
- discoverJobs();
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 { discoverListeners } from "@getstrata/bootstrap/discoverListeners";
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
- registerListenerGroup("cache.invalidate-on-model-write", () => {
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 { runProviderPhase } from "@getstrata/bootstrap/context";
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 { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
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: AppContext;
4575
+ context: InitializedAppContext;
4648
4576
  routes: AppRouteMap;
4649
4577
  config: ReturnType<typeof loadConfig>;
4650
4578
  }
4651
4579
 
4652
- class AppConfigStore {
4653
- private readonly values = new Map<string, unknown>();
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
- runProviderPhase(moduleProviders, "register", context);
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
- const isProduction = isProductionEnv();
4699
- // Dev boots migrate for convenience. Production must not mutate schema on
4700
- // start, so run \`strata migrate\` as an explicit deploy step instead.
4701
- const { migrate: runMigrate = !isProduction } = options;
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
- if (isProduction) {
4704
- assertProductionSecrets();
4705
- }
4594
+ if (isProduction) {
4595
+ assertProductionSecrets();
4596
+ }
4706
4597
 
4707
- ${needsEnsure(layers) ? ` await ensureAppDatabase();
4708
- ` : ""} const appConfig = loadConfig();
4709
- getSql();
4710
- if (isRlsTenancy()) {
4711
- await assertRlsLiveDatabaseRole();
4712
- }
4713
- configureModulesDirectory(join(import.meta.dir, "../modules"));
4714
- await ensureModulesLoaded();
4715
- const context = createAppContext();
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
- if (runMigrate) {
4718
- await migrate();
4719
- }
4609
+ if (runMigrate) {
4610
+ await migrate();
4611
+ }
4720
4612
 
4721
- const routes = mergeSpaRoutes(context.dependencies, {
4722
- ...createHealthRoutes(context.dependencies),
4723
- ...buildRoutes(context.dependencies),${metricsSpread}
4724
- }, {
4725
- distDirectory: join(import.meta.dir, "../../frontend/dist"),
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
- return { context, routes, config: appConfig };
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: () => bootstrapApp({ migrate: false }),
4796
- close: closeDatabase,
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
- const { bootstrapApp } = await import("../bootstrap/createApp.ts");
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.9",
16
- "@getstrata/cli": "^2.0.9",
17
- "@getstrata/core": "^2.0.9",
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
- return new CacheRepository(
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 { ensureModulesLoaded } from "@getstrata/bootstrap/discoverModules";
2
+ import {
3
+ installGracefulShutdownSignals,
4
+ LifecycleCoordinator,
5
+ } from "@getstrata/core/lifecycle/gracefulShutdown";
3
6
  import { bootstrapApp, createAppServer } from "./createApp.ts";
4
- import { closeDatabase, pingDatabase } from "./database.ts";
7
+ import { closeDatabase } from "./database.ts";
5
8
 
6
- await ensureModulesLoaded();
7
-
8
- const { routes, config } = await bootstrapApp();
9
-
10
- const server = createAppServer(routes, config.port);
11
-
12
- console.log(`Listening on http://localhost:${server.port} (APP_URL ${config.appUrl})`);
13
-
14
- if (!(await pingDatabase())) {
15
- console.warn("Warning: database ping failed.");
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
- process.on("SIGINT", shutdown);
25
- process.on("SIGTERM", shutdown);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/starter",
3
- "version": "2.0.9",
3
+ "version": "2.2.0",
4
4
  "description": "Interactive create-strata wizard. Choose each layer; one database engine.",
5
5
  "type": "module",
6
6
  "license": "MIT",