@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.1.0",
3195
- "@getstrata/cli": "^2.1.0",
3196
- "@getstrata/core": "^2.1.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, 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,15 +4048,13 @@ const notesTable = defineTable<NoteRecord, "id">({
4047
4048
  defaultOrderBy: { column: "id", direction: "ASC" },
4048
4049
  });
4049
4050
 
4050
- class Note extends Model<NoteRecord, "id"> {
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 { BaseRepository } from "@getstrata/core/database/baseRepository";
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 Model<UserRecord, "id"> {
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 { BaseRepository } from "@getstrata/core/database/baseRepository";
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 Model<ApiTokenRecord, "id"> {
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 { BaseRepository } from "@getstrata/core/database/baseRepository";
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 Model<AuthOneTimeTokenRecord, "id"> {
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(layers) {
4200
- const lines = [
4201
- "// Side-effect imports run registerModelRepository() in each model file.",
4202
- "// registerModelClass() is only for a string alias that is neither constructor.name nor $morphClass.",
4203
- 'import "./Note.ts";'
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
- container.set(
4423
- CORE_QUEUE_TOKEN,
4424
- createAppQueue(driver, process.env.REDIS_URL, failedJobs, () => {
4425
- registerDefaultJobs();
4426
- discoverJobs();
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 { discoverListeners } from "@getstrata/bootstrap/discoverListeners";
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
- registerListenerGroup("cache.invalidate-on-model-write", () => {
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 { runProviderPhase } from "@getstrata/bootstrap/context";
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 { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
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: AppContext;
4575
+ context: InitializedAppContext;
4622
4576
  routes: AppRouteMap;
4623
4577
  config: ReturnType<typeof loadConfig>;
4624
4578
  }
4625
4579
 
4626
- function createAppContext(): AppContext {
4627
- const container = new ServiceContainer();
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
- runProviderPhase(moduleProviders, "register", context);
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
- const isProduction = isProductionEnv();
4648
- // Dev boots migrate for convenience. Production must not mutate schema on
4649
- // start, so run \`strata migrate\` as an explicit deploy step instead.
4650
- 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;
4651
4593
 
4652
- if (isProduction) {
4653
- assertProductionSecrets();
4654
- }
4594
+ if (isProduction) {
4595
+ assertProductionSecrets();
4596
+ }
4655
4597
 
4656
- ${needsEnsure(layers) ? ` await ensureAppDatabase();
4657
- ` : ""} const appConfig = loadConfig();
4658
- getSql();
4659
- if (isRlsTenancy()) {
4660
- await assertRlsLiveDatabaseRole();
4661
- }
4662
- configureModulesDirectory(join(import.meta.dir, "../modules"));
4663
- await ensureModulesLoaded();
4664
- 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();
4665
4608
 
4666
- if (runMigrate) {
4667
- await migrate();
4668
- }
4609
+ if (runMigrate) {
4610
+ await migrate();
4611
+ }
4669
4612
 
4670
- const routes = mergeSpaRoutes(context.dependencies, {
4671
- ...createHealthRoutes(context.dependencies),
4672
- ...buildRoutes(context.dependencies),${metricsSpread}
4673
- }, {
4674
- distDirectory: join(import.meta.dir, "../../frontend/dist"),
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
- 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
+ }
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: () => bootstrapApp({ migrate: false }),
4745
- 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
+ },
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
- const { bootstrapApp } = await import("../bootstrap/createApp.ts");
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.1.0",
16
- "@getstrata/cli": "^2.1.0",
17
- "@getstrata/core": "^2.1.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
- 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.1.0",
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",