@stratum-hq/create 0.3.1 → 0.4.1

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/matrix.js CHANGED
@@ -28,19 +28,14 @@ var ALL_STRATEGIES = ["rls", "schema", "database", "collection", "table-prefix"]
28
28
  var ALL_ORMS = ["prisma", "drizzle", "sequelize", "knex", "mongoose", "pg"];
29
29
  function isValidPreset(preset) {
30
30
  const config = VALID_COMBINATIONS[preset.database];
31
- if (!config)
32
- return false;
33
- if (!config.strategies.includes(preset.strategy))
34
- return false;
35
- if (!config.orms.includes(preset.orm))
36
- return false;
37
- if (!config.frameworks.includes(preset.framework))
38
- return false;
31
+ if (!config) return false;
32
+ if (!config.strategies.includes(preset.strategy)) return false;
33
+ if (!config.orms.includes(preset.orm)) return false;
34
+ if (!config.frameworks.includes(preset.framework)) return false;
39
35
  return true;
40
36
  }
41
37
  function parsePresetString(s) {
42
- if (!s || typeof s !== "string")
43
- return null;
38
+ if (!s || typeof s !== "string") return null;
44
39
  const parts = s.toLowerCase().split("-");
45
40
  let database;
46
41
  let strategy;
@@ -56,14 +51,10 @@ function parsePresetString(s) {
56
51
  } else {
57
52
  return null;
58
53
  }
59
- if (!ALL_DATABASES.includes(database))
60
- return null;
61
- if (!ALL_STRATEGIES.includes(strategy))
62
- return null;
63
- if (!ALL_ORMS.includes(orm))
64
- return null;
65
- if (!ALL_FRAMEWORKS.includes(framework))
66
- return null;
54
+ if (!ALL_DATABASES.includes(database)) return null;
55
+ if (!ALL_STRATEGIES.includes(strategy)) return null;
56
+ if (!ALL_ORMS.includes(orm)) return null;
57
+ if (!ALL_FRAMEWORKS.includes(framework)) return null;
67
58
  return {
68
59
  database,
69
60
  strategy,
@@ -87,16 +78,11 @@ function getValidOptions(partial) {
87
78
  const frameworkSet = /* @__PURE__ */ new Set();
88
79
  for (const db of databases) {
89
80
  const config = VALID_COMBINATIONS[db];
90
- if (partial.strategy && !config.strategies.includes(partial.strategy))
91
- continue;
92
- if (partial.orm && !config.orms.includes(partial.orm))
93
- continue;
94
- for (const s of config.strategies)
95
- strategySet.add(s);
96
- for (const o of config.orms)
97
- ormSet.add(o);
98
- for (const f of config.frameworks)
99
- frameworkSet.add(f);
81
+ if (partial.strategy && !config.strategies.includes(partial.strategy)) continue;
82
+ if (partial.orm && !config.orms.includes(partial.orm)) continue;
83
+ for (const s of config.strategies) strategySet.add(s);
84
+ for (const o of config.orms) ormSet.add(o);
85
+ for (const f of config.frameworks) frameworkSet.add(f);
100
86
  }
101
87
  strategies = [...strategySet];
102
88
  orms = [...ormSet];
@@ -113,10 +99,8 @@ function getValidOptions(partial) {
113
99
  if (partial.strategy || partial.orm) {
114
100
  databases = databases.filter((db) => {
115
101
  const config = VALID_COMBINATIONS[db];
116
- if (partial.strategy && !config.strategies.includes(partial.strategy))
117
- return false;
118
- if (partial.orm && !config.orms.includes(partial.orm))
119
- return false;
102
+ if (partial.strategy && !config.strategies.includes(partial.strategy)) return false;
103
+ if (partial.orm && !config.orms.includes(partial.orm)) return false;
120
104
  return true;
121
105
  });
122
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stratum-hq/create",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Create a new Stratum multi-tenancy project",
5
5
  "keywords": [
6
6
  "multi-tenancy",
@@ -16,7 +16,7 @@
16
16
  "stratum"
17
17
  ],
18
18
  "bin": {
19
- "create-stratum": "./dist/index.js"
19
+ "create-stratum": "./dist/bin.js"
20
20
  },
21
21
  "exports": {
22
22
  ".": "./dist/index.js",
@@ -24,14 +24,14 @@
24
24
  },
25
25
  "type": "module",
26
26
  "scripts": {
27
- "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:@stratum-hq/* && esbuild src/matrix.ts --bundle --platform=node --format=esm --outfile=dist/matrix.js --external:@stratum-hq/*",
27
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:@stratum-hq/* && esbuild src/matrix.ts --bundle --platform=node --format=esm --outfile=dist/matrix.js --external:@stratum-hq/* && esbuild src/bin.ts --platform=node --format=esm --outfile=dist/bin.js",
28
28
  "typecheck": "tsc --noEmit",
29
29
  "lint": "eslint .",
30
30
  "test": "vitest run"
31
31
  },
32
32
  "devDependencies": {
33
- "esbuild": "^0.20.0",
34
- "vitest": "^1.6.0",
33
+ "esbuild": "^0.28.2",
34
+ "vitest": "^4.1.11",
35
35
  "typescript": "^5.3.0"
36
36
  },
37
37
  "publishConfig": {
package/src/bin.ts ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ // The bin calls main() without a check on process.argv[1]. npm runs the bin
4
+ // through a symlink, so a compare of argv[1] with the module path is false.
5
+ import { main } from "./index.js";
6
+
7
+ main(process.argv.slice(2));
@@ -24,9 +24,6 @@ export function generateDbSetup(preset: StackPreset): DbSetupFile[] {
24
24
 
25
25
  function generatePrismaSetup(preset: StackPreset): DbSetupFile[] {
26
26
  const provider = preset.database === "mysql" ? "mysql" : "postgresql";
27
- const envUrl = preset.database === "mysql"
28
- ? "mysql://user:password@localhost:3306/dbname"
29
- : "postgres://user:password@localhost:5432/dbname";
30
27
 
31
28
  return [
32
29
  {
@@ -121,9 +118,10 @@ export function createTenantDb(getTenantId: () => string) {
121
118
  return drizzleWithTenant(db, getTenantId, pool);
122
119
  }
123
120
 
124
- // Usage:
121
+ // Usage: run tenant queries inside transaction() so the tenant context and
122
+ // the query share one connection.
125
123
  // const tenantDb = createTenantDb(() => currentTenantId);
126
- // const rows = await tenantDb.select().from(orders);
124
+ // const rows = await tenantDb.transaction((tx) => tx.select().from(orders));
127
125
 
128
126
  export { pool };
129
127
  `,
@@ -205,60 +203,120 @@ export default config;
205
203
  },
206
204
  {
207
205
  filename: "src/stratum-knex.ts",
208
- content: `// Knex with Stratum tenant-scoped queries
206
+ content: preset.database === "postgres" ? KNEX_POSTGRES_SCOPE : knexColumnScope(preset.database),
207
+ },
208
+ ];
209
+ }
210
+
211
+ // PostgreSQL does not accept a bind parameter in SET, so the generated code
212
+ // calls set_config. The third argument (is_local = true) ends the setting with
213
+ // the transaction, so a pooled connection never keeps another tenant's ID.
214
+ const KNEX_POSTGRES_SCOPE = `// Knex with Stratum tenant-scoped queries
215
+ import createKnex, { type Knex } from "knex";
216
+ import config from "../knexfile.js";
217
+
218
+ const knex = createKnex(config);
219
+
220
+ // Run fn in a transaction that sets app.current_tenant_id, the setting that
221
+ // the RLS policies read. The setting ends with the transaction, so run every
222
+ // tenant query through trx, not through knex.
223
+ export async function withTenantScope<T>(
224
+ tenantId: string,
225
+ fn: (trx: Knex.Transaction) => Promise<T>,
226
+ ): Promise<T> {
227
+ return knex.transaction(async (trx) => {
228
+ await trx.raw("SELECT set_config('app.current_tenant_id', ?, true)", [tenantId]);
229
+ return fn(trx);
230
+ });
231
+ }
232
+
233
+ // Usage:
234
+ // const orders = await withTenantScope(currentTenantId, (trx) => trx("orders").select());
235
+
236
+ export { knex };
237
+ `;
238
+
239
+ function knexColumnScope(database: string): string {
240
+ return `// Knex with Stratum tenant-scoped queries
209
241
  import Knex from "knex";
210
242
  import config from "../knexfile.js";
211
243
 
212
244
  const knex = Knex(config);
213
245
 
214
246
  // Create a tenant-scoped query builder.
215
- // For RLS strategy, set the session variable before queries.
216
247
  export async function withTenantScope(tenantId: string, fn: (db: typeof knex) => Promise<void>) {
217
- ${preset.database === "postgres" ? ` await knex.raw("SET app.current_tenant = ?", [tenantId]);` : ` // For ${preset.database}, scope queries by tenant_id column`}
218
- try {
219
- await fn(knex);
220
- } finally {
221
- ${preset.database === "postgres" ? ` await knex.raw("RESET app.current_tenant");` : ` // Scope cleanup not needed for column-based isolation`}
222
- }
248
+ // For ${database}, scope queries by tenant_id column
249
+ await fn(knex);
223
250
  }
224
251
 
225
252
  export { knex };
226
- `,
227
- },
228
- ];
253
+ `;
254
+ }
255
+
256
+ // @stratum-hq/mongodb has no Mongoose connection helper. The generated code uses
257
+ // Mongoose directly and copies the tenant names of the @stratum-hq/mongodb
258
+ // adapters, so MongoDatabaseAdapter and MongoCollectionAdapter find the same
259
+ // data, for example for purgeTenantData.
260
+ function generateMongooseSetup(preset: StackPreset): DbSetupFile[] {
261
+ const tenantAccess =
262
+ preset.strategy === "collection"
263
+ ? `// Return the tenant's model for a base collection. The tenant's documents are
264
+ // in the collection {baseCollection}_{tenantSlug}, the name that
265
+ // MongoCollectionAdapter from @stratum-hq/mongodb uses.
266
+ export function getTenantModel<T>(
267
+ baseCollection: string,
268
+ schema: mongoose.Schema<T>,
269
+ tenantSlug: string,
270
+ ) {
271
+ const name = \`\${baseCollection}_\${assertSlug(tenantSlug)}\`;
272
+ return mainConnection.models[name] ?? mainConnection.model(name, schema, name);
229
273
  }
230
274
 
231
- function generateMongooseSetup(_preset: StackPreset): DbSetupFile[] {
275
+ // Usage:
276
+ // const Order = getTenantModel("orders", OrderSchema, "tenant_abc");
277
+ // const orders = await Order.find();`
278
+ : `// Return a connection to the tenant's own database. The database name is
279
+ // stratum_tenant_{tenantSlug}, the name that MongoDatabaseAdapter from
280
+ // @stratum-hq/mongodb uses. useCache returns the same connection on each call.
281
+ export function getTenantConnection(tenantSlug: string) {
282
+ return mainConnection.useDb(\`stratum_tenant_\${assertSlug(tenantSlug)}\`, { useCache: true });
283
+ }
284
+
285
+ // Usage:
286
+ // const conn = getTenantConnection("tenant_abc");
287
+ // const Order = conn.model("Order", OrderSchema);
288
+ // const orders = await Order.find();`;
289
+
232
290
  return [
233
291
  {
234
292
  filename: "src/stratum-mongoose.ts",
235
293
  content: `// Mongoose with Stratum multi-tenant support
236
294
  import mongoose from "mongoose";
237
- import { createTenantConnection } from "@stratum-hq/mongodb";
238
295
 
239
296
  // Main connection (used for tenant metadata)
240
297
  const mainConnection = mongoose.createConnection(
241
298
  process.env.MONGODB_URI || "mongodb://localhost:27017/main",
242
299
  );
243
300
 
244
- // Create a tenant-scoped connection.
245
- // Each tenant gets its own database (database-per-tenant strategy)
246
- // or collection prefix (collection strategy).
247
- export function getTenantConnection(tenantId: string) {
248
- return createTenantConnection(mainConnection, tenantId);
301
+ // A tenant slug becomes part of a MongoDB name, so it must match the Stratum
302
+ // slug rule: a lowercase letter, then lowercase letters, digits or underscores.
303
+ const SLUG_PATTERN = /^[a-z][a-z0-9_]{0,62}$/;
304
+
305
+ function assertSlug(tenantSlug: string): string {
306
+ if (!SLUG_PATTERN.test(tenantSlug)) {
307
+ throw new Error(\`Invalid tenant slug: "\${tenantSlug}"\`);
308
+ }
309
+ return tenantSlug;
249
310
  }
250
311
 
312
+ ${tenantAccess}
313
+
251
314
  // Define schemas that work across tenant connections
252
315
  export const TenantSchema = new mongoose.Schema({
253
316
  name: { type: String, required: true },
254
317
  createdAt: { type: Date, default: Date.now },
255
318
  });
256
319
 
257
- // Usage:
258
- // const conn = getTenantConnection("tenant-abc");
259
- // const Order = conn.model("Order", OrderSchema);
260
- // const orders = await Order.find();
261
-
262
320
  export { mainConnection };
263
321
  `,
264
322
  },
@@ -278,7 +336,11 @@ const pool = mysql.createPool({
278
336
  });
279
337
 
280
338
  // Execute a query scoped to a tenant by filtering on tenant_id
281
- export async function tenantQuery(tenantId: string, sql: string, params: unknown[] = []) {
339
+ export async function tenantQuery(
340
+ tenantId: string,
341
+ sql: string,
342
+ params: (string | number | bigint | boolean | Date | null)[] = [],
343
+ ) {
282
344
  const [rows] = await pool.execute(sql, [...params, tenantId]);
283
345
  return rows;
284
346
  }
@@ -304,9 +366,10 @@ const pool = new Pool({
304
366
  });
305
367
 
306
368
  // Create a tenant-scoped pool.
307
- // Sets app.current_tenant on each connection for RLS enforcement.
369
+ // Each query runs in a transaction that sets app.current_tenant_id, the
370
+ // setting that the RLS policies read.
308
371
  export function getTenantPool(tenantId: string) {
309
- return createTenantPool(pool, tenantId);
372
+ return createTenantPool(pool, () => tenantId);
310
373
  }
311
374
 
312
375
  // Usage:
@@ -1,5 +1,46 @@
1
1
  import type { StackPreset } from "../matrix.js";
2
2
 
3
+ /** Name of the non-superuser role a generated app connects as. */
4
+ export function postgresAppRole(dbName: string): string {
5
+ return `${dbName}_app`;
6
+ }
7
+
8
+ /** Local development password for the generated application role. */
9
+ export const POSTGRES_APP_PASSWORD = "dev_app_password";
10
+
11
+ /**
12
+ * SQL that creates the role the application connects as. POSTGRES_USER is a
13
+ * superuser, and a superuser (or any BYPASSRLS role) ignores every row-level
14
+ * security policy, FORCE included. So the app gets its own role, and the
15
+ * superuser is kept for bootstrap and migrations.
16
+ */
17
+ export function postgresAppRoleSql(dbName: string, strategy?: string): string {
18
+ const role = postgresAppRole(dbName);
19
+ let strategyGrant = "";
20
+ if (strategy === "schema") {
21
+ strategyGrant = `
22
+ -- schema-per-tenant: the app creates one schema per tenant
23
+ GRANT CREATE ON DATABASE ${dbName} TO ${role};
24
+ `;
25
+ } else if (strategy === "database") {
26
+ strategyGrant = `
27
+ -- database-per-tenant: the app creates one database per tenant
28
+ ALTER ROLE ${role} CREATEDB;
29
+ `;
30
+ }
31
+ return `
32
+ -- Application role. The app connects as ${role} (DATABASE_URL), never as the
33
+ -- bootstrap superuser (DATABASE_ADMIN_URL): a superuser or BYPASSRLS role
34
+ -- ignores every row-level security policy, FORCE included. Use the superuser
35
+ -- only for bootstrap and migrations.
36
+ CREATE ROLE ${role} WITH LOGIN PASSWORD '${POSTGRES_APP_PASSWORD}' NOSUPERUSER NOBYPASSRLS NOCREATEROLE;
37
+ GRANT CONNECT ON DATABASE ${dbName} TO ${role};
38
+ GRANT USAGE, CREATE ON SCHEMA public TO ${role};
39
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${role};
40
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${role};
41
+ ${strategyGrant}`;
42
+ }
43
+
3
44
  export function generatePresetInitSql(projectName: string, preset: StackPreset): string | null {
4
45
  const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
5
46
 
@@ -22,10 +63,16 @@ function generatePostgresInit(projectName: string, dbName: string, strategy: str
22
63
  -- Add RLS policies to each tenant-scoped table:
23
64
  --
24
65
  -- ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
66
+ -- ALTER TABLE your_table FORCE ROW LEVEL SECURITY;
25
67
  -- CREATE POLICY tenant_isolation ON your_table
26
- -- USING (tenant_id = current_setting('app.current_tenant')::uuid);
68
+ -- USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
27
69
  --
28
- -- The Stratum db-adapters package sets app.current_tenant automatically.
70
+ -- A pooled connection reads the setting as '' after a tenant transaction ends,
71
+ -- or as NULL before the first one. NULLIF makes both return no rows; a bare
72
+ -- ::uuid cast of '' raises an error.
73
+ -- FORCE makes the policy apply to the table owner too; without it, a table
74
+ -- created by the application role is not isolated for that role.
75
+ -- The Stratum db-adapters package sets app.current_tenant_id automatically.
29
76
  `;
30
77
  }
31
78
 
@@ -37,7 +84,7 @@ CREATE EXTENSION IF NOT EXISTS "ltree";
37
84
  -- The ltree extension enables hierarchical tenant trees
38
85
  -- uuid-ossp provides uuid_generate_v4() for tenant IDs
39
86
  COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
40
- ${rlsBlock}`;
87
+ ${postgresAppRoleSql(dbName, strategy)}${rlsBlock}`;
41
88
  }
42
89
 
43
90
  function generateMysqlInit(projectName: string, dbName: string): string {
@@ -33,11 +33,13 @@ const port = Number(process.env.PORT) || 3000;
33
33
 
34
34
  app.use(express.json());
35
35
 
36
- // Tenant extraction middleware
36
+ // Tenant extraction middleware. The tenant comes from the subdomain the
37
+ // request was routed to. Do not take it from a client-supplied header such as
38
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
39
+ // authentication, check that the signed-in user belongs to this tenant, or
40
+ // derive the tenant from the verified session or JWT instead.
37
41
  app.use((req, _res, next) => {
38
- const tenantId =
39
- req.headers["x-tenant-id"] as string ||
40
- req.hostname.split(".")[0];
42
+ const tenantId = req.hostname.split(".")[0];
41
43
  (req as any).tenantId = tenantId;
42
44
  next();
43
45
  });
@@ -68,12 +70,14 @@ function generateFastifyMiddleware(projectName: string): MiddlewareFile[] {
68
70
  const fastify = Fastify({ logger: true });
69
71
  const port = Number(process.env.PORT) || 3000;
70
72
 
71
- // Tenant extraction plugin
73
+ // Tenant extraction hook. The tenant comes from the subdomain the
74
+ // request was routed to. Do not take it from a client-supplied header such as
75
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
76
+ // authentication, check that the signed-in user belongs to this tenant, or
77
+ // derive the tenant from the verified session or JWT instead.
72
78
  fastify.decorateRequest("tenantId", "");
73
79
  fastify.addHook("onRequest", async (request) => {
74
- const tenantId =
75
- (request.headers["x-tenant-id"] as string) ||
76
- (request.hostname?.split(".")[0] ?? "");
80
+ const tenantId = request.hostname?.split(".")[0] ?? "";
77
81
  (request as any).tenantId = tenantId;
78
82
  });
79
83
 
@@ -105,14 +109,15 @@ function generateNextjsMiddleware(projectName: string): MiddlewareFile[] {
105
109
  import { NextRequest, NextResponse } from "next/server";
106
110
 
107
111
  export function middleware(request: NextRequest) {
112
+ // The tenant comes from the subdomain the request was routed to. Any
113
+ // x-tenant-id the client sent is removed first, so server code that reads
114
+ // x-tenant-id only ever sees the value set here. Once you add
115
+ // authentication, check that the signed-in user belongs to this tenant.
108
116
  const hostname = request.headers.get("host") || "";
109
- const subdomain = hostname.split(".")[0];
110
- const headerTenantId = request.headers.get("x-tenant-id");
111
- const pathTenantId = request.nextUrl.pathname.match(/^\\/tenant\\/([^/]+)/)?.[1];
112
-
113
- const tenantId = headerTenantId || pathTenantId || subdomain;
117
+ const tenantId = hostname.split(".")[0];
114
118
 
115
119
  const requestHeaders = new Headers(request.headers);
120
+ requestHeaders.delete("x-tenant-id");
116
121
  if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
117
122
  requestHeaders.set("x-tenant-id", tenantId);
118
123
  }
@@ -135,7 +140,7 @@ export default function Home() {
135
140
  <p>Multi-tenant app powered by Stratum.</p>
136
141
  <ul>
137
142
  <li>Configure tenants via the Stratum control plane</li>
138
- <li>Access tenant context via <code>x-tenant-id</code> header</li>
143
+ <li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
139
144
  <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
140
145
  </ul>
141
146
  </main>
@@ -155,11 +160,13 @@ import { serve } from "@hono/node-server";
155
160
 
156
161
  const app = new Hono();
157
162
 
158
- // Tenant extraction middleware
163
+ // Tenant extraction middleware. The tenant comes from the subdomain the
164
+ // request was routed to. Do not take it from a client-supplied header such as
165
+ // x-tenant-id: any caller can set one and pick another tenant. Once you add
166
+ // authentication, check that the signed-in user belongs to this tenant, or
167
+ // derive the tenant from the verified session or JWT instead.
159
168
  app.use("*", async (c, next) => {
160
- const tenantId =
161
- c.req.header("x-tenant-id") ||
162
- new URL(c.req.url).hostname.split(".")[0];
169
+ const tenantId = new URL(c.req.url).hostname.split(".")[0];
163
170
  c.set("tenantId", tenantId);
164
171
  await next();
165
172
  });
@@ -239,9 +246,11 @@ export class AppController {
239
246
  export class TenantGuard implements CanActivate {
240
247
  canActivate(context: ExecutionContext): boolean {
241
248
  const request = context.switchToHttp().getRequest();
242
- const tenantId =
243
- request.headers["x-tenant-id"] ||
244
- request.hostname?.split(".")[0];
249
+ // The tenant comes from the subdomain the request was routed to. Do not
250
+ // take it from a client-supplied header such as x-tenant-id: any caller
251
+ // can set one and pick another tenant. Once you add authentication, check
252
+ // that the signed-in user belongs to this tenant here.
253
+ const tenantId = request.hostname?.split(".")[0];
245
254
  request.tenantId = tenantId || null;
246
255
  return true;
247
256
  }
@@ -1,16 +1,24 @@
1
1
  import type { StackPreset } from "../matrix.js";
2
+ import { rootSources } from "./tsconfig.js";
3
+ import { STRATUM_RANGES } from "../stratum-versions.js";
2
4
 
3
5
  export function generatePresetPackageJson(projectName: string, preset: StackPreset): string {
4
6
  const deps: Record<string, string> = {
5
- "@stratum-hq/lib": "^0.2.0",
7
+ "@stratum-hq/lib": STRATUM_RANGES["@stratum-hq/lib"],
6
8
  };
7
9
  const devDeps: Record<string, string> = {
8
10
  typescript: "^5.3.0",
9
11
  "@types/node": "^20.11.0",
10
12
  };
13
+ // NestJS injection needs the decorator metadata that tsc emits and tsx does not.
14
+ if (preset.framework === "nestjs") {
15
+ devDeps["tsc-watch"] = "^7.2.0";
16
+ } else if (preset.framework !== "nextjs") {
17
+ devDeps["tsx"] = "^4.7.0";
18
+ }
11
19
 
12
20
  // Database driver deps
13
- addDatabaseDeps(deps, preset);
21
+ addDatabaseDeps(deps, devDeps, preset);
14
22
 
15
23
  // ORM deps
16
24
  addOrmDeps(deps, devDeps, preset);
@@ -41,9 +49,14 @@ export function generatePresetPackageJson(projectName: string, preset: StackPres
41
49
  );
42
50
  }
43
51
 
44
- function addDatabaseDeps(deps: Record<string, string>, preset: StackPreset): void {
52
+ function addDatabaseDeps(deps: Record<string, string>, devDeps: Record<string, string>, preset: StackPreset): void {
45
53
  switch (preset.database) {
46
54
  case "postgres":
55
+ // The generated code for these ORMs imports pg itself. The strict tsc
56
+ // build fails with TS7016 when the pg types are not installed.
57
+ if (preset.orm === "pg" || preset.orm === "prisma" || preset.orm === "drizzle") {
58
+ devDeps["@types/pg"] = "^8.11.0";
59
+ }
47
60
  if (preset.orm !== "prisma" && preset.orm !== "drizzle" && preset.orm !== "sequelize") {
48
61
  deps["pg"] = "^8.11.0";
49
62
  }
@@ -139,19 +152,19 @@ function addFrameworkDeps(deps: Record<string, string>, devDeps: Record<string,
139
152
 
140
153
  function addStratumDeps(deps: Record<string, string>, preset: StackPreset): void {
141
154
  if (preset.database === "postgres" && preset.orm !== "mongoose") {
142
- deps["@stratum-hq/db-adapters"] = "^0.2.0";
155
+ deps["@stratum-hq/db-adapters"] = STRATUM_RANGES["@stratum-hq/db-adapters"];
143
156
  }
144
157
  if (preset.database === "mongodb") {
145
- deps["@stratum-hq/mongodb"] = "^0.2.0";
158
+ deps["@stratum-hq/mongodb"] = STRATUM_RANGES["@stratum-hq/mongodb"];
146
159
  }
147
160
  if (preset.database === "mysql") {
148
- deps["@stratum-hq/mysql"] = "^0.2.0";
161
+ deps["@stratum-hq/mysql"] = STRATUM_RANGES["@stratum-hq/mysql"];
149
162
  }
150
163
  if (preset.framework === "hono") {
151
- deps["@stratum-hq/hono"] = "^0.2.0";
164
+ deps["@stratum-hq/hono"] = STRATUM_RANGES["@stratum-hq/hono"];
152
165
  }
153
166
  if (preset.framework === "nestjs") {
154
- deps["@stratum-hq/nestjs"] = "^0.2.0";
167
+ deps["@stratum-hq/nestjs"] = STRATUM_RANGES["@stratum-hq/nestjs"];
155
168
  }
156
169
  }
157
170
 
@@ -159,10 +172,16 @@ function getScripts(preset: StackPreset): Record<string, string> {
159
172
  if (preset.framework === "nextjs") {
160
173
  return { dev: "next dev", build: "next build", start: "next start" };
161
174
  }
162
- if (preset.framework === "nestjs") {
163
- return { dev: "node --watch src/main.ts", build: "tsc", start: "node dist/main.js" };
164
- }
165
- return { dev: "node --watch src/index.js", build: "tsc", start: "node dist/index.js" };
175
+ const entry = preset.framework === "nestjs" ? "main" : "index";
176
+ // When tsc compiles from the project root, src/ is emitted to dist/src/.
177
+ const emittedDir = rootSources(preset).length > 0 ? "dist/src" : "dist";
178
+ // Node 20 cannot run a .ts file. NestJS compiles with tsc, which keeps the
179
+ // decorator metadata its injection needs; the other frameworks run through tsx.
180
+ const dev =
181
+ preset.framework === "nestjs"
182
+ ? `tsc-watch --onSuccess "node --env-file=.env ${emittedDir}/${entry}.js"`
183
+ : `tsx watch --env-file=.env src/${entry}.ts`;
184
+ return { dev, build: "tsc", start: `node ${emittedDir}/${entry}.js` };
166
185
  }
167
186
 
168
187
  function sortKeys(obj: Record<string, string>): Record<string, string> {
@@ -1,7 +1,6 @@
1
1
  import type { StackPreset } from "../matrix.js";
2
2
 
3
3
  export function generatePresetReadme(projectName: string, preset: StackPreset): string {
4
- const devCmd = getDevCommand(preset);
5
4
  const dbStartCmd = getDbStartInfo(preset);
6
5
  const dbSetupNote = getDbSetupNote(preset);
7
6
 
@@ -37,7 +36,7 @@ ${dbSetupNote}
37
36
  ### 4. Run the app
38
37
 
39
38
  \`\`\`bash
40
- ${devCmd}
39
+ npm run dev
41
40
  \`\`\`
42
41
 
43
42
  ## Multi-tenancy
@@ -50,13 +49,6 @@ See the [Stratum docs](https://github.com/stratum-hq/Stratum) for full reference
50
49
  `;
51
50
  }
52
51
 
53
- function getDevCommand(preset: StackPreset): string {
54
- if (preset.framework === "nextjs") return "npm run dev";
55
- if (preset.framework === "nestjs") return "node --env-file=.env src/main.ts";
56
- if (preset.framework === "none") return "node --env-file=.env src/index.ts";
57
- return "node --env-file=.env src/index.ts";
58
- }
59
-
60
52
  function getDbStartInfo(preset: StackPreset): string {
61
53
  switch (preset.database) {
62
54
  case "postgres":
@@ -95,7 +87,7 @@ function getStrategyDescription(strategy: string): string {
95
87
  switch (strategy) {
96
88
  case "rls":
97
89
  return `- **Row-Level Security** -- PostgreSQL RLS policies filter rows by tenant automatically
98
- - Each query sets \`app.current_tenant\` and RLS enforces isolation
90
+ - Each query sets \`app.current_tenant_id\` and RLS enforces isolation
99
91
  - All tenants share one database and schema`;
100
92
  case "schema":
101
93
  return `- **Schema-per-tenant** -- each tenant gets a dedicated PostgreSQL schema
@@ -0,0 +1,48 @@
1
+ import type { Framework, StackPreset } from "../matrix.js";
2
+
3
+ /**
4
+ * Return the source files that a preset writes at the project root and that code in `src/` imports.
5
+ *
6
+ * @param preset - The stack preset of the generated project.
7
+ */
8
+ export function rootSources(preset: StackPreset): string[] {
9
+ // The knex CLI reads knexfile.ts from the project root, and src/stratum-knex.ts imports it.
10
+ return preset.orm === "knex" ? ["knexfile.ts"] : [];
11
+ }
12
+
13
+ /**
14
+ * Return the tsconfig.json content for a generated project.
15
+ *
16
+ * @param framework - The framework of the project. It adds the compiler options that the framework needs.
17
+ * @param extraSources - Source files at the project root that code in `src/` imports.
18
+ * When there are any, tsc compiles from the project root, so the output of `src/` goes to `dist/src/`.
19
+ */
20
+ export function generateTsconfig(framework: Framework, extraSources: string[] = []): string {
21
+ // tsc rejects an imported file outside rootDir with TS6059.
22
+ const rootDir = extraSources.length > 0 ? "." : "src";
23
+ return JSON.stringify(
24
+ {
25
+ compilerOptions: {
26
+ target: "ESNext",
27
+ module: "NodeNext",
28
+ moduleResolution: "NodeNext",
29
+ strict: true,
30
+ outDir: "dist",
31
+ rootDir,
32
+ declaration: true,
33
+ skipLibCheck: true,
34
+ esModuleInterop: true,
35
+ ...(framework === "nestjs"
36
+ ? { experimentalDecorators: true, emitDecoratorMetadata: true }
37
+ : {}),
38
+ ...(framework === "nextjs"
39
+ ? { jsx: "preserve", plugins: [{ name: "next" }] }
40
+ : {}),
41
+ },
42
+ include: ["src", ...extraSources],
43
+ exclude: ["node_modules", "dist"],
44
+ },
45
+ null,
46
+ 2,
47
+ );
48
+ }