@getstrata/starter 1.1.0 → 1.1.2

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/README.md CHANGED
@@ -34,7 +34,7 @@ Pick one database engine. Docker Compose is optional (`--docker`, `--no-docker`,
34
34
 
35
35
  A Bun and TypeScript app on `@getstrata/core` and `@getstrata/bootstrap` that boots, migrates, and passes `bun run check`. Cookie apps include a restyleable welcome page, `/login`, `/register`, and password reset. Token apps include `POST /api/v1/auth/login` plus register and reset. Tenancy is `none`, `column` (any engine), or `rls` (Postgres only; other engines fall back to `column`). Choices are recorded in `strata.layers.json`.
36
36
 
37
- The `strata` binary installs into the app rather than globally, so use the generated `bun run` scripts.
37
+ The `strata` binary installs into the app rather than globally, so use the generated `bun run` scripts. Generated apps ship `src/cli/register.ts` with `queue:work` (`bun run queue:work`, requires `REDIS_URL`), `make:*`, `openapi:*`, and `schedule:run`. `queue:work` boots that app rather than the framework-repo worker.
38
38
 
39
39
  ## Docs
40
40
 
package/dist/cli.js CHANGED
@@ -2856,9 +2856,9 @@ function renderPackageJson(projectName, options = {}) {
2856
2856
  "@getstrata/cli": "workspace:*",
2857
2857
  "@getstrata/core": "workspace:*"
2858
2858
  } : {
2859
- "@getstrata/bootstrap": "^1.1.0",
2860
- "@getstrata/cli": "^1.1.0",
2861
- "@getstrata/core": "^1.1.0"
2859
+ "@getstrata/bootstrap": "^1.1.2",
2860
+ "@getstrata/cli": "^1.1.2",
2861
+ "@getstrata/core": "^1.1.2"
2862
2862
  };
2863
2863
  coreDeps.eta = "^4.6.0";
2864
2864
  if (options.layers?.database === "mysql") {
@@ -2869,6 +2869,7 @@ function renderPackageJson(projectName, options = {}) {
2869
2869
  start: "strata start",
2870
2870
  "db:migrate": "strata migrate",
2871
2871
  "db:fresh": "strata migrate:fresh",
2872
+ "queue:work": "strata queue:work",
2872
2873
  check: "tsc --noEmit"
2873
2874
  };
2874
2875
  if (options.layers && needsFrontendBuild(options.layers.frontend)) {
@@ -3081,7 +3082,7 @@ ${next.join(`
3081
3082
  `)}
3082
3083
  \`\`\`
3083
3084
 
3084
- Open http://localhost:3000. Health check: \`GET /health\`.
3085
+ Open http://localhost:3000. Health check: \`GET /health\`. Redis worker: \`bun run queue:work\` (requires \`REDIS_URL\`).
3085
3086
 
3086
3087
  ${renderSupportingToolsReadme(layers)}${layers.auth !== "headers" ? `
3087
3088
  Seeded login (password \`StrataDemo!ChangeMe\`):
@@ -3146,7 +3147,7 @@ The image sets \`APP_ENV=production\` and \`AUTH_DEV_HEADERS=false\`; everything
3146
3147
  - Behind a reverse proxy or load balancer, set \`TRUST_FORWARDED_FOR=true\` so throttles and session records see the client address instead of the proxy. Only the rightmost public hop of \`X-Forwarded-For\` is trusted.
3147
3148
  ${authUsesCookie(layers.auth) ? "- Set `SESSION_SECRET` to 32+ characters.\n" : ""}${authUsesToken(layers.auth) ? "- Set `TOKEN_HASH_PEPPER`.\n" : ""}${layers.extras.scim ? "- Set `SCIM_BEARER_TOKEN`.\n" : ""}${layers.extras.metrics ? "- Set `METRICS_TOKEN`.\n" : ""}${layers.tenancy === "rls" ? `- \`DATABASE_URL\` must be a \`NOBYPASSRLS\` role, not the \`postgres\` superuser. Generated apps create \`${GENERATED_POSTGRES_APP_ROLE}\` via \`db/ensure-postgres-app-role.sql\` (and Compose init on first empty volume). \`MIGRATION_DATABASE_URL\` may stay the superuser for CREATE ROLE / GRANT / migrate. Production boot rejects username \`postgres\` or \`root\`, then inspects \`pg_roles\` for \`rolsuper\` / \`rolbypassrls\`.
3148
3149
  ` : ""}
3149
- \`strata start\` does not migrate when \`APP_ENV=production\`. Run \`bun run db:migrate\` as a deploy step. \`GET /health\` is 200 when \`notes\` is readable (including zero rows) and 503 when that read fails.
3150
+ \`strata start\` does not migrate when \`APP_ENV=production\`. Run \`bun run db:migrate\` as a deploy step. \`GET /health\` is 200 when \`notes\` is readable (including zero rows) and 503 when that read fails. Redis jobs need a worker: \`bun run queue:work\` (requires \`REDIS_URL\`).
3150
3151
  `;
3151
3152
  }
3152
3153
 
@@ -3473,11 +3474,19 @@ export async function closeDatabase() {
3473
3474
  }
3474
3475
  `;
3475
3476
  }
3476
- function renderMigrateTs(layers) {
3477
+ function starterSchemaStatements(layers) {
3477
3478
  const d = dialectFragments(layers.database);
3478
3479
  const statements = [];
3479
3480
  const tenancyOn = usesTenantTable(layers.tenancy);
3480
3481
  const mfaOn = layers.extras.mfa && authNeedsUsers(layers.auth);
3482
+ const payloadType = layers.database === "postgres" ? "JSONB NOT NULL" : `${d.text} NOT NULL`;
3483
+ statements.push(`CREATE TABLE IF NOT EXISTS failed_job (
3484
+ id ${d.id},
3485
+ job_name ${d.keyText} NOT NULL,
3486
+ payload ${payloadType},
3487
+ exception ${d.text} NOT NULL,
3488
+ failed_at ${d.timestamp}
3489
+ )`);
3481
3490
  if (tenancyOn) {
3482
3491
  statements.push(`CREATE TABLE IF NOT EXISTS tenant (
3483
3492
  id ${d.id},
@@ -3564,8 +3573,87 @@ function renderMigrateTs(layers) {
3564
3573
  }
3565
3574
  statements.push(generatedRlsBootstrapSql(rlsTables, userOwnedTables).trim());
3566
3575
  }
3567
- const list = statements.map((sql) => ` \`${sql.replace(/`/g, "\\`")}\`,`).join(`
3576
+ return statements;
3577
+ }
3578
+ function renderStarterSchemaMigration(layers) {
3579
+ const statements = starterSchemaStatements(layers);
3580
+ const cascade = layers.database === "sqlite" ? "" : " CASCADE";
3581
+ const upCalls = statements.map((sql) => ` await db.unsafe(${JSON.stringify(sql)});`).join(`
3568
3582
  `);
3583
+ const downCalls = dropTables(layers).map((table) => ` await db.unsafe("DROP TABLE IF EXISTS ${table}${cascade}");`).join(`
3584
+ `);
3585
+ return `import type { Migration } from "@getstrata/core/database/migrations/types";
3586
+
3587
+ const migration: Migration = {
3588
+ name: "0001_starter_schema",
3589
+ async up(db) {
3590
+ ${upCalls}
3591
+ },
3592
+ async down(db) {
3593
+ ${downCalls}
3594
+ },
3595
+ };
3596
+
3597
+ export default migration;
3598
+ `;
3599
+ }
3600
+ function renderMigrationRuntimeTs(layers) {
3601
+ if (layers.database === "postgres") {
3602
+ return `import { join } from "node:path";
3603
+ import { loadMigrationsFromDirectory } from "@getstrata/core/database/migrations";
3604
+ import type { MigrationDatabase } from "@getstrata/core/database/migrations/types";
3605
+ import {
3606
+ grantPostgresAppRolePrivileges,
3607
+ openPostgresAdminConnection,
3608
+ postgresDatabaseNameFromUrl,
3609
+ } from "@getstrata/core/tenant/enableTenantRls";
3610
+
3611
+ const MIGRATIONS_DIRECTORY = join(import.meta.dir, "migrations");
3612
+
3613
+ export async function loadStarterMigrations() {
3614
+ return loadMigrationsFromDirectory(MIGRATIONS_DIRECTORY);
3615
+ }
3616
+
3617
+ export async function withMigrationDatabase<T>(
3618
+ fn: (db: MigrationDatabase) => Promise<T>,
3619
+ ): Promise<T> {
3620
+ const runtimeUrl = process.env.DATABASE_URL ?? "";
3621
+ const admin = await openPostgresAdminConnection({
3622
+ runtimeUrl,
3623
+ migrationUrl: process.env.MIGRATION_DATABASE_URL,
3624
+ });
3625
+ try {
3626
+ const result = await fn(admin);
3627
+ await grantPostgresAppRolePrivileges(admin, {
3628
+ database: postgresDatabaseNameFromUrl(runtimeUrl),
3629
+ });
3630
+ return result;
3631
+ } finally {
3632
+ await admin.close?.();
3633
+ }
3634
+ }
3635
+ `;
3636
+ }
3637
+ return `import { join } from "node:path";
3638
+ import { loadMigrationsFromDirectory } from "@getstrata/core/database/migrations";
3639
+ import type { MigrationDatabase } from "@getstrata/core/database/migrations/types";
3640
+ import { getSql } from "../bootstrap/database.ts";
3641
+
3642
+ const MIGRATIONS_DIRECTORY = join(import.meta.dir, "migrations");
3643
+
3644
+ export async function loadStarterMigrations() {
3645
+ return loadMigrationsFromDirectory(MIGRATIONS_DIRECTORY);
3646
+ }
3647
+
3648
+ export async function withMigrationDatabase<T>(
3649
+ fn: (db: MigrationDatabase) => Promise<T>,
3650
+ ): Promise<T> {
3651
+ return fn(getSql());
3652
+ }
3653
+ `;
3654
+ }
3655
+ function renderMigrateTs(layers) {
3656
+ const tenancyOn = usesTenantTable(layers.tenancy);
3569
3657
  const ph = layers.database === "postgres";
3570
3658
  const verifyOn = layers.extras.emailVerification && authNeedsUsers(layers.auth);
3571
3659
  const verifiedNow = nowTimestampLiteral(layers.database);
@@ -3600,8 +3688,6 @@ function renderMigrateTs(layers) {
3600
3688
  const hashImport = authNeedsUsers(layers.auth) ? `import { hashPassword } from "@getstrata/core/auth/password";
3601
3689
  ` : "";
3602
3690
  const rlsBypassImport = layers.tenancy === "rls" && layers.database === "postgres" ? `import { runWithMigrationBypass } from "@getstrata/core/tenant/databaseTenantContext";
3603
- ` : "";
3604
- const postgresRoleImport = layers.database === "postgres" ? `import { grantPostgresAppRolePrivileges, openPostgresAdminConnection, postgresDatabaseNameFromUrl } from "@getstrata/core/tenant/enableTenantRls";
3605
3691
  ` : "";
3606
3692
  const bindSql = seedTenant || layers.tenancy === "rls" && layers.database === "postgres" ? ` const sql = getSql();
3607
3693
  ` : "";
@@ -3612,15 +3698,13 @@ function renderMigrateTs(layers) {
3612
3698
  const noteCreate = tenancyOn ? `await Note.create({ body: "Welcome to Strata!", tenant_id: 1 });` : `await Note.create({ body: "Welcome to Strata!" });`;
3613
3699
  const userImport = authNeedsUsers(layers.auth) ? `import { User } from "../models/User.ts";
3614
3700
  ` : "";
3615
- const needsSqlClient = layers.database !== "postgres" || Boolean(seedTenant) || layers.tenancy === "rls" && layers.database === "postgres";
3701
+ const needsSqlClient = Boolean(seedTenant) || layers.tenancy === "rls" && layers.database === "postgres";
3616
3702
  const databaseImport = needsSqlClient ? `import { closeDatabase, getSql } from "../bootstrap/database.ts";
3617
3703
  ` : `import { closeDatabase } from "../bootstrap/database.ts";
3618
3704
  `;
3619
- return `${hashImport}${rlsBypassImport}${postgresRoleImport}${ensureImport(layers)}${databaseImport}import { Note } from "../models/Note.ts";
3620
- ${userImport}
3621
- const migrations = [
3622
- ${list}
3623
- ];
3705
+ return `${hashImport}${rlsBypassImport}${ensureImport(layers)}${databaseImport}import { migrateDatabase } from "@getstrata/core/database/migrations";
3706
+ import { Note } from "../models/Note.ts";
3707
+ ${userImport}import { loadStarterMigrations, withMigrationDatabase } from "./migrationRuntime.ts";
3624
3708
 
3625
3709
  export async function seed() {
3626
3710
  ${ensureCall(layers)}${bindSql}${seedOpen}${seedTenant}
@@ -3630,26 +3714,10 @@ ${ensureCall(layers)}${bindSql}${seedOpen}${seedTenant}
3630
3714
  }
3631
3715
 
3632
3716
  export async function migrate() {
3633
- ${ensureCall(layers)}${layers.database === "postgres" ? ` const runtimeUrl = process.env.DATABASE_URL ?? "";
3634
- const admin = await openPostgresAdminConnection({
3635
- runtimeUrl,
3636
- migrationUrl: process.env.MIGRATION_DATABASE_URL,
3717
+ ${ensureCall(layers)} await withMigrationDatabase(async (db) => {
3718
+ await migrateDatabase(db, await loadStarterMigrations());
3637
3719
  });
3638
- try {
3639
- for (const statement of migrations) {
3640
- await admin.unsafe(statement);
3641
- }
3642
- await grantPostgresAppRolePrivileges(admin, {
3643
- database: postgresDatabaseNameFromUrl(runtimeUrl),
3644
- });
3645
- } finally {
3646
- await admin.close?.();
3647
- }
3648
- ` : ` const sql = getSql();
3649
- for (const statement of migrations) {
3650
- await sql.unsafe(statement);
3651
- }
3652
- `} await seed();
3720
+ await seed();
3653
3721
  }
3654
3722
 
3655
3723
  /** The CLI calls this after migrate() so pooled drivers do not hold the process open. */
@@ -3862,7 +3930,7 @@ export { AuthOneTimeToken };
3862
3930
  `;
3863
3931
  }
3864
3932
  function dropTables(layers) {
3865
- const ordered = [];
3933
+ const ordered = ["failed_job"];
3866
3934
  if (authUsesToken(layers.auth)) {
3867
3935
  ordered.push("api_tokens");
3868
3936
  }
@@ -3880,31 +3948,17 @@ function dropTables(layers) {
3880
3948
  }
3881
3949
  return ordered;
3882
3950
  }
3883
- function renderPostgresAdminDropCall() {
3884
- return ` await dropPostgresTablesAsAdmin(tables, {
3885
- runtimeUrl: process.env.DATABASE_URL ?? "",
3886
- migrationUrl: process.env.MIGRATION_DATABASE_URL,
3887
- });
3888
- `;
3889
- }
3890
3951
  function renderFreshTs(layers) {
3891
- const tables = dropTables(layers);
3892
- const cascade = layers.database === "sqlite" ? "" : " CASCADE";
3893
- const imports = layers.database === "postgres" ? `import { dropPostgresTablesAsAdmin } from "@getstrata/core/tenant/enableTenantRls";
3894
- ${ensureImport(layers)}import { closeDatabase } from "../bootstrap/database.ts";
3895
- import { migrate } from "./migrate.ts";` : `${ensureImport(layers)}import { closeDatabase, getSql } from "../bootstrap/database.ts";
3896
- import { migrate } from "./migrate.ts";`;
3897
- const dropBody = layers.database === "postgres" ? `${renderPostgresAdminDropCall()} await migrate();` : ` const sql = getSql();
3898
- for (const table of tables) {
3899
- await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
3900
- }
3901
- await migrate();`;
3902
- return `${imports}
3903
-
3904
- const tables = ${JSON.stringify(tables)};
3952
+ return `${ensureImport(layers)}import { freshDatabase } from "@getstrata/core/database/migrations";
3953
+ import { closeDatabase } from "../bootstrap/database.ts";
3954
+ import { seed } from "./migrate.ts";
3955
+ import { loadStarterMigrations, withMigrationDatabase } from "./migrationRuntime.ts";
3905
3956
 
3906
3957
  export async function fresh() {
3907
- ${ensureCall(layers)}${dropBody}
3958
+ ${ensureCall(layers)} await withMigrationDatabase(async (db) => {
3959
+ await freshDatabase(db, await loadStarterMigrations());
3960
+ });
3961
+ await seed();
3908
3962
  }
3909
3963
 
3910
3964
  /** The CLI calls this after fresh() so pooled drivers do not hold the process open. */
@@ -3933,23 +3987,17 @@ if (import.meta.main) {
3933
3987
  `;
3934
3988
  }
3935
3989
  function renderStatusTs(layers) {
3936
- const tables = dropTables(layers);
3937
- return `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";
3938
-
3939
- const tables = ${JSON.stringify(tables)};
3990
+ return `${ensureImport(layers)}import { getMigrationStatus } from "@getstrata/core/database/migrations";
3991
+ import { loadStarterMigrations, withMigrationDatabase } from "./migrationRuntime.ts";
3940
3992
 
3941
3993
  export async function status() {
3942
- ${ensureCall(layers)} const sql = getSql();
3943
- console.log("Starter schema (inline SQL, not a migration runner):");
3944
- for (const table of tables) {
3945
- try {
3946
- const rows = await sql.unsafe<{ count: string | number }>(
3947
- \`SELECT COUNT(*) AS count FROM \${table}\`,
3948
- );
3949
- console.log(\`- [present] \${table} (rows: \${rows[0]?.count ?? 0})\`);
3950
- } catch {
3951
- console.log(\`- [missing] \${table}\`);
3952
- }
3994
+ ${ensureCall(layers)} const rows = await withMigrationDatabase(async (db) => {
3995
+ return getMigrationStatus(db, await loadStarterMigrations());
3996
+ });
3997
+ console.log("Migrations:");
3998
+ for (const row of rows) {
3999
+ const batch = row.batch == null ? "" : \` (batch \${row.batch})\`;
4000
+ console.log(\`- [\${row.status}] \${row.name}\${batch}\`);
3953
4001
  }
3954
4002
  }
3955
4003
 
@@ -3960,28 +4008,25 @@ if (import.meta.main) {
3960
4008
  `;
3961
4009
  }
3962
4010
  function renderRollbackTs(layers) {
3963
- const tables = dropTables(layers);
3964
- const cascade = layers.database === "sqlite" ? "" : " CASCADE";
3965
- const imports = layers.database === "postgres" ? `import { dropPostgresTablesAsAdmin } from "@getstrata/core/tenant/enableTenantRls";
3966
- ${ensureImport(layers)}` : `${ensureImport(layers)}import { getSql } from "../bootstrap/database.ts";`;
3967
- const dropBody = layers.database === "postgres" ? `${renderPostgresAdminDropCall()} for (const table of tables) {
3968
- console.log(\`dropped \${table}\`);
3969
- }` : ` const sql = getSql();
3970
- for (const table of tables) {
3971
- await sql.unsafe(\`DROP TABLE IF EXISTS \${table}${cascade}\`);
3972
- console.log(\`dropped \${table}\`);
3973
- }`;
3974
- return `${imports}
3975
-
3976
- const tables = ${JSON.stringify(tables)};
4011
+ return `${ensureImport(layers)}import { rollbackDatabase } from "@getstrata/core/database/migrations";
4012
+ import { loadStarterMigrations, withMigrationDatabase } from "./migrationRuntime.ts";
3977
4013
 
3978
4014
  export async function rollback() {
3979
- ${ensureCall(layers)}${dropBody}
4015
+ ${ensureCall(layers)} const rolledBack = await withMigrationDatabase(async (db) => {
4016
+ return rollbackDatabase(db, await loadStarterMigrations(), {
4017
+ onMigration: (name) => {
4018
+ console.log(\`rolled back \${name}\`);
4019
+ },
4020
+ });
4021
+ });
4022
+ if (rolledBack === 0) {
4023
+ console.log("Nothing to roll back.");
4024
+ }
3980
4025
  }
3981
4026
 
3982
4027
  if (import.meta.main) {
3983
4028
  await rollback();
3984
- console.log("Rolled back starter tables.");
4029
+ console.log("Rolled back last migration batch.");
3985
4030
  process.exit(0);
3986
4031
  }
3987
4032
  `;
@@ -4067,7 +4112,9 @@ export default configProvider;
4067
4112
  `;
4068
4113
  }
4069
4114
  function renderQueueProvider() {
4070
- return `import type { ServiceProvider } from "@getstrata/core/contracts/di";
4115
+ return `import { discoverJobs } from "@getstrata/bootstrap/discoverJobs";
4116
+ import { registerDefaultJobs } from "@getstrata/bootstrap/queue/defaultJobs";
4117
+ import type { ServiceProvider } from "@getstrata/core/contracts/di";
4071
4118
  import { CORE_QUEUE_TOKEN } from "@getstrata/core/contracts/serviceTokens";
4072
4119
  import {
4073
4120
  createAppQueue,
@@ -4081,7 +4128,13 @@ const queueProvider: ServiceProvider = {
4081
4128
  const driver = (process.env.QUEUE_DRIVER ?? "sync") as "sync" | "async" | "redis";
4082
4129
  const failedJobs = createFailedJobService();
4083
4130
  container.set(FAILED_JOB_SERVICE_TOKEN, failedJobs);
4084
- container.set(CORE_QUEUE_TOKEN, createAppQueue(driver, process.env.REDIS_URL, failedJobs));
4131
+ container.set(
4132
+ CORE_QUEUE_TOKEN,
4133
+ createAppQueue(driver, process.env.REDIS_URL, failedJobs, () => {
4134
+ registerDefaultJobs();
4135
+ discoverJobs();
4136
+ }),
4137
+ );
4085
4138
  },
4086
4139
  };
4087
4140
 
@@ -4185,7 +4238,9 @@ export default policyProvider;
4185
4238
  `;
4186
4239
  }
4187
4240
  function renderProvidersIndex() {
4188
- return `import type { ServiceProvider } from "@getstrata/core/contracts/di";
4241
+ return `import { discoverListeners } from "@getstrata/bootstrap/discoverListeners";
4242
+ import { registerInvalidateCacheOnModelWriteListeners } from "@getstrata/bootstrap/listeners/invalidateCacheOnModelWrite";
4243
+ import type { ServiceProvider } from "@getstrata/core/contracts/di";
4189
4244
  import authProvider from "./auth.ts";
4190
4245
  import cacheProvider from "./cache.ts";
4191
4246
  import configProvider from "./config.ts";
@@ -4193,6 +4248,30 @@ import policyProvider from "./policy.ts";
4193
4248
  import queueProvider from "./queue.ts";
4194
4249
  import storageProvider from "./storage.ts";
4195
4250
 
4251
+ const registeredListenerGroups = new Set<string>();
4252
+
4253
+ function registerListenerGroup(name: string, register: () => void): void {
4254
+ if (registeredListenerGroups.has(name)) {
4255
+ return;
4256
+ }
4257
+
4258
+ registeredListenerGroups.add(name);
4259
+ register();
4260
+ }
4261
+
4262
+ const listenersProvider: ServiceProvider = {
4263
+ name: "starter.listeners",
4264
+ boot() {
4265
+ registerListenerGroup("cache.invalidate-on-model-write", () => {
4266
+ registerInvalidateCacheOnModelWriteListeners();
4267
+ });
4268
+
4269
+ for (const registerListener of discoverListeners()) {
4270
+ registerListener();
4271
+ }
4272
+ },
4273
+ };
4274
+
4196
4275
  const starterProviders: ServiceProvider[] = [
4197
4276
  configProvider,
4198
4277
  cacheProvider,
@@ -4200,6 +4279,7 @@ const starterProviders: ServiceProvider[] = [
4200
4279
  queueProvider,
4201
4280
  authProvider,
4202
4281
  policyProvider,
4282
+ listenersProvider,
4203
4283
  ];
4204
4284
 
4205
4285
  export { starterProviders };
@@ -4228,6 +4308,7 @@ import {
4228
4308
  import { mergeSpaRoutes } from "@getstrata/bootstrap/createSpaRoutes";
4229
4309
  import {
4230
4310
  configureModulesDirectory,
4311
+ discoverModules,
4231
4312
  ensureModulesLoaded,
4232
4313
  } from "@getstrata/bootstrap/discoverModules";
4233
4314
  import { createHealthRoutes } from "@getstrata/bootstrap/health";
@@ -4286,6 +4367,10 @@ function createAppContext(): AppContext {
4286
4367
  runProviderPhase(starterProviders, "register", context);
4287
4368
  runProviderPhase(starterProviders, "boot", context);
4288
4369
 
4370
+ const moduleProviders = discoverModules().flatMap((module) => module.providers ?? []);
4371
+ runProviderPhase(moduleProviders, "register", context);
4372
+ runProviderPhase(moduleProviders, "boot", context);
4373
+
4289
4374
  assertAppDependenciesComplete(dependencies);
4290
4375
 
4291
4376
  const appContext = { container, config, dependencies: dependencies as AppDependencies };
@@ -4356,6 +4441,116 @@ export function buildRoutes(dependencies: AppDependencies): AppRouteMap {
4356
4441
  }
4357
4442
  `;
4358
4443
  }
4444
+ function renderCliRegisterTs() {
4445
+ return `import type { StrataCommandMap } from "@getstrata/cli";
4446
+
4447
+ const commands: StrataCommandMap = {
4448
+ "make:module": async () => (await import("@getstrata/cli/scaffold")).makeModuleCommand,
4449
+ "make:policy": async () => (await import("@getstrata/cli/scaffold")).makePolicyCommand,
4450
+ "make:job": async () => (await import("@getstrata/cli/scaffold")).makeJobCommand,
4451
+ "make:listener": async () => (await import("@getstrata/cli/scaffold")).makeListenerCommand,
4452
+ "make:request": async () => (await import("@getstrata/cli/scaffold")).makeRequestCommand,
4453
+ "make:factory": async () => (await import("@getstrata/cli/scaffold")).makeFactoryCommand,
4454
+ "make:migration": async () => (await import("@getstrata/cli/scaffold")).makeMigrationCommand,
4455
+ "queue:work": async () => (await import("./queueWork.ts")).queueWorkCommand,
4456
+ "queue:failed": async () => (await import("./queueFailed.ts")).queueFailedCommand,
4457
+ "queue:retry": async () => (await import("./queueFailed.ts")).queueRetryCommand,
4458
+ "queue:flush-failed": async () => (await import("./queueFailed.ts")).queueFlushFailedCommand,
4459
+ "openapi:generate": async () => (await import("./openapi.ts")).openapiGenerateCommand,
4460
+ "openapi:validate": async () => (await import("./openapi.ts")).openapiValidateCommand,
4461
+ "openapi:check": async () => (await import("./openapi.ts")).openapiCheckCommand,
4462
+ "schedule:run": async () => (await import("./scheduleRun.ts")).scheduleRunCommand,
4463
+ };
4464
+
4465
+ export { commands };
4466
+ `;
4467
+ }
4468
+ function renderCliQueueWorkTs() {
4469
+ return `async function queueWorkCommand(): Promise<void> {
4470
+ if (!process.env.REDIS_URL) {
4471
+ throw new Error("queue:work requires REDIS_URL to be set.");
4472
+ }
4473
+
4474
+ const { runQueueWorkerCommand } = await import("@getstrata/cli/queueWorker");
4475
+ const { bootstrapApp } = await import("../bootstrap/createApp.ts");
4476
+ const { closeDatabase } = await import("../bootstrap/database.ts");
4477
+
4478
+ await runQueueWorkerCommand({
4479
+ boot: () => bootstrapApp({ migrate: false }),
4480
+ close: closeDatabase,
4481
+ });
4482
+ }
4483
+
4484
+ export { queueWorkCommand };
4485
+ `;
4486
+ }
4487
+ function renderCliQueueFailedTs() {
4488
+ return `async function bootApp(): Promise<void> {
4489
+ const { bootstrapApp } = await import("../bootstrap/createApp.ts");
4490
+ await bootstrapApp({ migrate: false });
4491
+ }
4492
+
4493
+ async function queueFailedCommand(): Promise<void> {
4494
+ const { queueFailedCommand: run } = await import("@getstrata/cli/queueFailed");
4495
+ await run(bootApp);
4496
+ }
4497
+
4498
+ async function queueRetryCommand(id?: string): Promise<void> {
4499
+ const { queueRetryCommand: run } = await import("@getstrata/cli/queueFailed");
4500
+ await run(id, bootApp);
4501
+ }
4502
+
4503
+ async function queueFlushFailedCommand(): Promise<void> {
4504
+ const { queueFlushFailedCommand: run } = await import("@getstrata/cli/queueFailed");
4505
+ await run(bootApp);
4506
+ }
4507
+
4508
+ export { queueFailedCommand, queueFlushFailedCommand, queueRetryCommand };
4509
+ `;
4510
+ }
4511
+ function renderCliOpenApiTs() {
4512
+ return `async function bootstrap() {
4513
+ const { createApp } = await import("../bootstrap/createApp.ts");
4514
+ return createApp({ migrate: false });
4515
+ }
4516
+
4517
+ async function openapiGenerateCommand(): Promise<void> {
4518
+ const { createOpenApiGenerateCommand } = await import("@getstrata/cli/openapi");
4519
+ await createOpenApiGenerateCommand(bootstrap)();
4520
+ }
4521
+
4522
+ async function openapiValidateCommand(): Promise<void> {
4523
+ const { createOpenApiValidateCommand } = await import("@getstrata/cli/openapi");
4524
+ await createOpenApiValidateCommand(bootstrap)();
4525
+ }
4526
+
4527
+ async function openapiCheckCommand(): Promise<void> {
4528
+ const { createOpenApiCheckCommand } = await import("@getstrata/cli/openapi");
4529
+ await createOpenApiCheckCommand(bootstrap)();
4530
+ }
4531
+
4532
+ export { openapiCheckCommand, openapiGenerateCommand, openapiValidateCommand };
4533
+ `;
4534
+ }
4535
+ function renderCliScheduleRunTs() {
4536
+ return `async function scheduleRunCommand(): Promise<void> {
4537
+ const { createScheduleRunCommand } = await import("@getstrata/cli/schedule");
4538
+ await createScheduleRunCommand(async () => {
4539
+ const { bootstrapApp } = await import("../bootstrap/createApp.ts");
4540
+ await bootstrapApp({ migrate: false });
4541
+ await import("../bootstrap/schedule.ts");
4542
+ })();
4543
+ }
4544
+
4545
+ export { scheduleRunCommand };
4546
+ `;
4547
+ }
4548
+ function renderScheduleTs() {
4549
+ return `import { appSchedule } from "@getstrata/core/scheduler/schedule";
4550
+
4551
+ export { appSchedule };
4552
+ `;
4553
+ }
4359
4554
  function renderViewTs(layers) {
4360
4555
  const authImport = authNeedsUsers(layers.auth) ? `import { currentAuthUser } from "@getstrata/core/auth/authContext";
4361
4556
  import { resolveCsrfTokenForRequest } from "@getstrata/core/http/csrfToken";
@@ -4805,6 +5000,12 @@ function writeGeneratedFiles(options) {
4805
5000
  removeIfExists(join2(src, "bootstrap/ensureDatabase.ts"));
4806
5001
  }
4807
5002
  writeText(join2(src, "bootstrap/createApp.ts"), renderCreateAppTs(layers));
5003
+ writeText(join2(src, "bootstrap/schedule.ts"), renderScheduleTs());
5004
+ writeText(join2(src, "cli/register.ts"), renderCliRegisterTs());
5005
+ writeText(join2(src, "cli/queueWork.ts"), renderCliQueueWorkTs());
5006
+ writeText(join2(src, "cli/queueFailed.ts"), renderCliQueueFailedTs());
5007
+ writeText(join2(src, "cli/openapi.ts"), renderCliOpenApiTs());
5008
+ writeText(join2(src, "cli/scheduleRun.ts"), renderCliScheduleRunTs());
4808
5009
  writeText(join2(src, "bootstrap/providers/config.ts"), renderConfigProvider(layers));
4809
5010
  writeText(join2(src, "bootstrap/providers/queue.ts"), renderQueueProvider());
4810
5011
  writeText(join2(src, "bootstrap/providers/policy.ts"), renderPolicyProvider());
@@ -4824,6 +5025,8 @@ function writeGeneratedFiles(options) {
4824
5025
  removeIfExists(join2(src, "models/ApiToken.ts"));
4825
5026
  }
4826
5027
  writeText(join2(src, "db/migrate.ts"), renderMigrateTs(layers));
5028
+ writeText(join2(src, "db/migrationRuntime.ts"), renderMigrationRuntimeTs(layers));
5029
+ writeText(join2(src, "db/migrations/0001_starter_schema.ts"), renderStarterSchemaMigration(layers));
4827
5030
  writeText(join2(src, "db/fresh.ts"), renderFreshTs(layers));
4828
5031
  writeText(join2(src, "db/seed.ts"), renderSeedTs());
4829
5032
  writeText(join2(src, "db/status.ts"), renderStatusTs(layers));
@@ -8,12 +8,13 @@
8
8
  "start": "strata start",
9
9
  "db:migrate": "strata migrate",
10
10
  "db:fresh": "strata migrate:fresh",
11
+ "queue:work": "strata queue:work",
11
12
  "check": "tsc --noEmit"
12
13
  },
13
14
  "dependencies": {
14
- "@getstrata/bootstrap": "^1.1.0",
15
- "@getstrata/cli": "^1.1.0",
16
- "@getstrata/core": "^1.1.0",
15
+ "@getstrata/bootstrap": "^1.1.2",
16
+ "@getstrata/cli": "^1.1.2",
17
+ "@getstrata/core": "^1.1.2",
17
18
  "eta": "^4.6.0"
18
19
  },
19
20
  "devDependencies": {
@@ -2,6 +2,7 @@ export default {
2
2
  preload: "./src/bootstrap/preload.ts",
3
3
  server: "./src/bootstrap/server.ts",
4
4
  modulesDirectory: "./src/modules",
5
+ commands: "./src/cli/register.ts",
5
6
  migrate: "./src/db/migrate.ts",
6
7
  fresh: "./src/db/fresh.ts",
7
8
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/starter",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Interactive create-strata wizard. Choose each layer; one database engine.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,95 +0,0 @@
1
- import { runProviderPhase } from "@getstrata/bootstrap/context";
2
- import {
3
- type AppContext,
4
- type AppDependencies,
5
- type AppRouteMap,
6
- assertAppDependenciesComplete,
7
- type ConfigStore,
8
- type MutableAppDependencies,
9
- type ProviderContext,
10
- ServiceContainer,
11
- } from "@getstrata/bootstrap/contracts";
12
- import { createWebServer } from "@getstrata/bootstrap/web/server";
13
- import { isProductionEnv } from "@getstrata/core/runtime/appEnv";
14
- import { setActiveApplicationContext } from "@getstrata/core/runtime/applicationRegistry";
15
- import { migrate } from "../db/migrate.ts";
16
- import { buildRoutes } from "../routes.ts";
17
- import { loadConfig } from "./config.ts";
18
- import { starterProviders } from "./providers/index.ts";
19
-
20
- export interface BootstrapOptions {
21
- migrate?: boolean;
22
- }
23
-
24
- export interface BootstrappedApp {
25
- context: AppContext;
26
- routes: AppRouteMap;
27
- config: ReturnType<typeof loadConfig>;
28
- }
29
-
30
- class AppConfigStore {
31
- private readonly values = new Map<string, unknown>();
32
-
33
- set<T>(key: string, value: T): T {
34
- this.values.set(key, value);
35
- return value;
36
- }
37
-
38
- get<T>(key: string): T | undefined {
39
- return this.values.get(key) as T | undefined;
40
- }
41
-
42
- require<T>(key: string): T {
43
- const value = this.get<T>(key);
44
- if (value === undefined) {
45
- throw new Error(`Missing required config value "${key}".`);
46
- }
47
- return value;
48
- }
49
-
50
- has(key: string): boolean {
51
- return this.values.has(key);
52
- }
53
- }
54
-
55
- function createAppContext(): AppContext {
56
- const container = new ServiceContainer();
57
- const config = new AppConfigStore() as unknown as ConfigStore;
58
- const dependencies: MutableAppDependencies = { container };
59
- const context: ProviderContext = { container, config, dependencies };
60
-
61
- runProviderPhase(starterProviders, "register", context);
62
- runProviderPhase(starterProviders, "boot", context);
63
-
64
- assertAppDependenciesComplete(dependencies);
65
-
66
- const appContext = { container, config, dependencies: dependencies as AppDependencies };
67
- setActiveApplicationContext(appContext as never);
68
- return appContext;
69
- }
70
-
71
- export async function bootstrapApp(options: BootstrapOptions = {}): Promise<BootstrappedApp> {
72
- const isProduction = isProductionEnv();
73
- const { migrate: runMigrate = !isProduction } = options;
74
-
75
- const appConfig = loadConfig();
76
- const context = createAppContext();
77
-
78
- if (runMigrate) {
79
- await migrate();
80
- }
81
-
82
- const routes = buildRoutes(context.dependencies);
83
-
84
- return { context, routes, config: appConfig };
85
- }
86
-
87
- export function createAppServer(routes: AppRouteMap, port = 0) {
88
- return createWebServer({
89
- port,
90
- publicDir: "./public",
91
- routes,
92
- });
93
- }
94
-
95
- export { createAppContext };