create-sailor 1.4.4 → 1.6.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.
Files changed (39) hide show
  1. package/dist/index.js +173 -173
  2. package/package.json +13 -14
  3. package/templates/backends/gateway/README.md +13 -0
  4. package/templates/backends/gateway/package.json +22 -0
  5. package/templates/backends/gateway/src/index.ts +16 -0
  6. package/templates/backends/gateway/tsconfig.json +13 -0
  7. package/templates/backends/python/README.md +16 -0
  8. package/templates/backends/python/_shared/__init__.py +5 -0
  9. package/templates/backends/python/pyproject.toml +13 -0
  10. package/templates/e2e/README.md +11 -0
  11. package/templates/e2e/golden/signup.spec.ts +6 -0
  12. package/templates/e2e/playwright.config.ts +14 -0
  13. package/templates/e2e/sleptons/community.spec.ts +6 -0
  14. package/templates/e2e/smoke/health.spec.ts +6 -0
  15. package/templates/infra/README.md +10 -0
  16. package/templates/infra/data/README.md +3 -0
  17. package/templates/infra/iac/README.md +3 -0
  18. package/templates/infra/ops/README.md +3 -0
  19. package/templates/infra/runtime/README.md +3 -0
  20. package/templates/orm/drizzle/README.md +58 -0
  21. package/templates/orm/drizzle/drizzle.config.ts +16 -0
  22. package/templates/orm/drizzle/migrations/.gitkeep +0 -0
  23. package/templates/orm/drizzle/package.json +29 -0
  24. package/templates/orm/drizzle/src/index.ts +31 -0
  25. package/templates/orm/drizzle/src/schema/auth.ts +73 -0
  26. package/templates/orm/drizzle/src/schema/billing.ts +37 -0
  27. package/templates/orm/drizzle/src/schema/index.ts +3 -0
  28. package/templates/orm/drizzle/src/schema/tenant.ts +57 -0
  29. package/templates/orm/drizzle/tsconfig.json +16 -0
  30. package/templates/tests/README.md +8 -0
  31. package/templates/tests/architecture/no-cycles.test.ts +7 -0
  32. package/templates/tests/load/smoke.k6.js +13 -0
  33. package/templates/workflows/README.md +9 -0
  34. package/templates/workflows/inngest/README.md +3 -0
  35. package/templates/workflows/inngest/welcome-email.ts +14 -0
  36. package/templates/workflows/n8n/README.md +3 -0
  37. package/templates/workflows/n8n/example-flow.json +6 -0
  38. package/templates/workflows/pusher/README.md +3 -0
  39. package/templates/workflows/pusher/events.ts +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-sailor",
3
- "version": "1.4.4",
3
+ "version": "1.6.0",
4
4
  "description": "Governed AI-native SaaS scaffolder for Nebutra Sailor. Bootstrap a production-ready Next.js + Hono + Prisma monorepo with multi-tenant foundations, region-aware defaults, and AI integrations.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -13,14 +13,6 @@
13
13
  "README.md",
14
14
  "LICENSE"
15
15
  ],
16
- "scripts": {
17
- "dev": "tsup --watch",
18
- "build": "tsup",
19
- "prepublishOnly": "pnpm run build",
20
- "start": "node dist/index.js",
21
- "test": "vitest run",
22
- "test:watch": "vitest"
23
- },
24
16
  "dependencies": {
25
17
  "@clack/prompts": "^0.7.0",
26
18
  "@mrleebo/prisma-ast": "^0.15.0",
@@ -32,11 +24,11 @@
32
24
  "update-notifier": "^7.3.1"
33
25
  },
34
26
  "devDependencies": {
35
- "@types/node": "catalog:",
27
+ "@types/node": "^22.19.15",
36
28
  "@types/update-notifier": "^6.0.8",
37
- "tsup": "catalog:",
38
- "typescript": "catalog:",
39
- "vitest": "catalog:"
29
+ "tsup": "^8.5.1",
30
+ "typescript": "^5.9.3",
31
+ "vitest": "^4.1.4"
40
32
  },
41
33
  "engines": {
42
34
  "node": ">=18.17.0"
@@ -80,5 +72,12 @@
80
72
  "publishConfig": {
81
73
  "access": "public",
82
74
  "registry": "https://registry.npmjs.org/"
75
+ },
76
+ "scripts": {
77
+ "dev": "tsup --watch",
78
+ "build": "tsup",
79
+ "start": "node dist/index.js",
80
+ "test": "vitest run",
81
+ "test:watch": "vitest"
83
82
  }
84
- }
83
+ }
@@ -0,0 +1,13 @@
1
+ # backends/gateway
2
+
3
+ TypeScript / Hono BFF. The **default** backend for `{PRODUCT_NAME}`.
4
+
5
+ Handles auth, tenancy, rate-limiting, and routing in front of all other services.
6
+
7
+ CRUD, webhooks, billing, third-party API proxies, and any new backend work goes here unless one of the Python exceptions applies (see `backends/python/README.md`).
8
+
9
+ ## Develop
10
+
11
+ ```bash
12
+ pnpm --filter @{PRODUCT_NAME}/gateway dev
13
+ ```
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@{PRODUCT_NAME}/gateway",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "scripts": {
8
+ "dev": "tsx watch src/index.ts",
9
+ "build": "tsup src/index.ts --format esm --target node20",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "hono": "^4.6.0",
14
+ "@hono/node-server": "^1.13.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "catalog:",
18
+ "tsx": "^4.19.0",
19
+ "tsup": "catalog:",
20
+ "typescript": "catalog:"
21
+ }
22
+ }
@@ -0,0 +1,16 @@
1
+ import { serve } from "@hono/node-server";
2
+ import { Hono } from "hono";
3
+
4
+ // import { tenantMiddleware } from "@nebutra/tenant/middleware";
5
+ // import { fromHeader } from "@nebutra/tenant/resolvers";
6
+
7
+ const app = new Hono();
8
+
9
+ // app.use("*", tenantMiddleware({ resolvers: [fromHeader("x-tenant-id")] }));
10
+
11
+ app.get("/health", (c) => c.json({ status: "ok" }));
12
+
13
+ const port = Number(process.env.PORT ?? 8080);
14
+ serve({ fetch: app.fetch, port });
15
+
16
+ export default app;
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "resolveJsonModule": true,
10
+ "noEmit": true
11
+ },
12
+ "include": ["src/**/*.ts"]
13
+ }
@@ -0,0 +1,16 @@
1
+ # backends/python
2
+
3
+ FastAPI-based Python backends. **Use only when justified.**
4
+
5
+ Per the TS-by-Default ADR (`docs/architecture/2026-05-10-ts-by-default-python-only-when-justified.md`), a new Python service is acceptable only when it cites at least one of:
6
+
7
+ 1. **Batch / queued work** too long for edge runtimes (>5s typical)
8
+ 2. **ML / scientific compute** that depends on the Python ecosystem (transformers, vLLM, etc.)
9
+ 3. **Specialized libraries** with no comparable TS port
10
+
11
+ For CRUD, webhooks, billing, content management — use `backends/gateway/` (TS) instead.
12
+
13
+ ## Layout
14
+
15
+ - `_shared/` — shared primitives (queue client, db, logger) consumed by services
16
+ - `<service>/` — one folder per active service (must have a real caller)
@@ -0,0 +1,5 @@
1
+ """Shared primitives for {PRODUCT_NAME} Python backends.
2
+
3
+ Active modules go here once they have real callers. Empty stubs should stay
4
+ in `incubator/` (excluded from workspaces / CI) until promoted.
5
+ """
@@ -0,0 +1,13 @@
1
+ [project]
2
+ name = "{PRODUCT_NAME}-python-backends"
3
+ version = "0.1.0"
4
+ description = "Python backends for {PRODUCT_NAME} — batch / ML / specialized only."
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "fastapi>=0.115.0",
8
+ "uvicorn[standard]>=0.32.0",
9
+ "pydantic>=2.9.0",
10
+ ]
11
+
12
+ [tool.uv.workspace]
13
+ members = ["_shared", "*"]
@@ -0,0 +1,11 @@
1
+ # e2e
2
+
3
+ Playwright end-to-end tests for `{PRODUCT_NAME}`.
4
+
5
+ | Folder | Scope |
6
+ |--------|-------|
7
+ | `smoke/` | Fast smoke tests — must pass on every PR |
8
+ | `golden/` | Critical-path "golden" flows (signup → first action → billing) |
9
+ | `sleptons/` | Sleptons-specific scenarios (skip if Sleptons is disabled) |
10
+
11
+ Run with `pnpm exec playwright test --config=playwright.config.ts`.
@@ -0,0 +1,6 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ test.skip("golden: signup → dashboard → first action", async ({ page }) => {
4
+ await page.goto("/sign-up");
5
+ await expect(page.getByRole("heading")).toBeVisible();
6
+ });
@@ -0,0 +1,14 @@
1
+ import { defineConfig, devices } from "@playwright/test";
2
+
3
+ export default defineConfig({
4
+ testDir: ".",
5
+ fullyParallel: true,
6
+ forbidOnly: !!process.env.CI,
7
+ retries: process.env.CI ? 2 : 0,
8
+ reporter: process.env.CI ? "github" : "list",
9
+ use: {
10
+ baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
11
+ trace: "on-first-retry",
12
+ },
13
+ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
14
+ });
@@ -0,0 +1,6 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ test.skip("sleptons: community feed renders", async ({ page }) => {
4
+ await page.goto("/sleptons");
5
+ await expect(page).toHaveURL(/sleptons/);
6
+ });
@@ -0,0 +1,6 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ test("homepage renders", async ({ page }) => {
4
+ await page.goto("/");
5
+ await expect(page).toHaveTitle(/.+/);
6
+ });
@@ -0,0 +1,10 @@
1
+ # infra
2
+
3
+ Infrastructure-as-code, runtime configs, data pipelines, and ops scripts for `{PRODUCT_NAME}`.
4
+
5
+ | Folder | Purpose |
6
+ |--------|---------|
7
+ | `iac/` | Terraform / Pulumi / CDK — provisioning cloud resources |
8
+ | `runtime/` | Container, edge, and serverless runtime configs (Dockerfiles, fly.toml, etc.) |
9
+ | `data/` | DB migrations, ETL definitions, ClickHouse schemas |
10
+ | `ops/` | Operational scripts (backup, restore, on-call runbooks) |
@@ -0,0 +1,3 @@
1
+ # infra/data
2
+
3
+ DB migrations, ETL definitions, ClickHouse schemas, and other persistent-data assets that aren't owned by a single app.
@@ -0,0 +1,3 @@
1
+ # infra/iac
2
+
3
+ Provisioning: Terraform / Pulumi / CDK. Put one subfolder per stack (e.g. `aws/`, `cloudflare/`).
@@ -0,0 +1,3 @@
1
+ # infra/ops
2
+
3
+ Operational scripts and runbooks — backup, restore, on-call playbooks, incident response.
@@ -0,0 +1,3 @@
1
+ # infra/runtime
2
+
3
+ Container, edge, and serverless runtime configs — Dockerfiles, `fly.toml`, ECS task definitions, etc.
@@ -0,0 +1,58 @@
1
+ # @nebutra/db-drizzle
2
+
3
+ Opt-in Drizzle ORM access layer. Lives alongside `@nebutra/db` (Prisma).
4
+
5
+ ## When to use which
6
+
7
+ | Scenario | Use |
8
+ |---|---|
9
+ | New code, want SQL-shaped query builder | `@nebutra/db-drizzle` |
10
+ | Existing auth / billing / audit / oauth flows | `@nebutra/db` (Prisma — they're built on it) |
11
+ | Migrations + schema source of truth | `@nebutra/db` (Prisma owns schema) |
12
+
13
+ Both connect to the same `DATABASE_URL`. Treat Drizzle's `src/schema/*.ts` as
14
+ **read-mostly mirrors** of the Prisma schema until the dual-ORM era ends.
15
+
16
+ ## Setup
17
+
18
+ ```bash
19
+ pnpm --filter @nebutra/db-drizzle db:generate # generate migrations from schema
20
+ pnpm --filter @nebutra/db-drizzle db:push # push schema directly (dev only)
21
+ pnpm --filter @nebutra/db-drizzle db:studio # open Drizzle Studio
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```ts
27
+ import { db, schema } from "@nebutra/db-drizzle";
28
+ import { eq } from "drizzle-orm";
29
+
30
+ // Read
31
+ const user = await db.query.users.findFirst({
32
+ where: eq(schema.users.email, "alice@example.com"),
33
+ });
34
+
35
+ // Insert
36
+ await db.insert(schema.users).values({
37
+ id: crypto.randomUUID(),
38
+ name: "Alice",
39
+ email: "alice@example.com",
40
+ });
41
+ ```
42
+
43
+ ## Schema coverage
44
+
45
+ Currently mirrored (read-safe with the Prisma side):
46
+
47
+ - `auth.user` / `auth.session` / `auth.account` / `auth.verification`
48
+ - `auth.organization` / `auth.member` / `auth.invitation`
49
+ - `public.subscriptions` / `public.usage_ledger`
50
+
51
+ Add more in `src/schema/*.ts` as you migrate features off Prisma. Keep
52
+ column names + types in lock-step with `packages/platform/db/prisma/schema.prisma`.
53
+
54
+ ## Migration path (dual-ORM → Drizzle primary)
55
+
56
+ Not on the roadmap until enough new code lands on Drizzle to make the swap
57
+ worth the risk. Until then, write against whichever client matches your use
58
+ case and don't double-write the same table from both.
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ if (!process.env.DATABASE_URL) {
4
+ throw new Error("DATABASE_URL is not set — required for drizzle-kit");
5
+ }
6
+
7
+ export default defineConfig({
8
+ schema: "./src/schema/*.ts",
9
+ out: "./migrations",
10
+ dialect: "postgresql",
11
+ dbCredentials: {
12
+ url: process.env.DATABASE_URL,
13
+ },
14
+ verbose: true,
15
+ strict: true,
16
+ });
File without changes
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@nebutra/db-drizzle",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./schema": "./src/schema/index.ts",
11
+ "./schema/*": "./src/schema/*.ts"
12
+ },
13
+ "scripts": {
14
+ "db:generate": "drizzle-kit generate",
15
+ "db:migrate": "drizzle-kit migrate",
16
+ "db:push": "drizzle-kit push",
17
+ "db:studio": "drizzle-kit studio",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "drizzle-orm": "^0.36.4",
22
+ "postgres": "^3.4.5"
23
+ },
24
+ "devDependencies": {
25
+ "drizzle-kit": "^0.30.1",
26
+ "typescript": "^5.7.2",
27
+ "@types/node": "^22.10.5"
28
+ }
29
+ }
@@ -0,0 +1,31 @@
1
+ import { drizzle } from "drizzle-orm/postgres-js";
2
+ import postgres from "postgres";
3
+ import * as schema from "./schema";
4
+
5
+ /**
6
+ * @nebutra/db-drizzle — opt-in Drizzle ORM access layer.
7
+ *
8
+ * Lives alongside the primary Prisma client at @nebutra/db. Both connect to
9
+ * the same DATABASE_URL — Drizzle is for new code that wants the
10
+ * lighter-weight, SQL-shaped query builder; Prisma stays as the entry point
11
+ * for the auth / billing / audit packages that were built on top of it.
12
+ *
13
+ * Migration policy: Prisma owns the schema until the dual-ORM era ends.
14
+ * `drizzle-kit generate` here is for *adding* Drizzle-only tables; existing
15
+ * Prisma tables get mirrored into src/schema/*.ts for read access only.
16
+ */
17
+
18
+ const connectionString = process.env.DATABASE_URL;
19
+ if (!connectionString) {
20
+ throw new Error("[@nebutra/db-drizzle] DATABASE_URL is not set. Drizzle cannot connect.");
21
+ }
22
+
23
+ const client = postgres(connectionString, {
24
+ max: Number(process.env.DATABASE_POOL_MAX ?? 10),
25
+ idle_timeout: 30,
26
+ prepare: false,
27
+ });
28
+
29
+ export const db = drizzle(client, { schema, logger: false });
30
+ export { schema };
31
+ export type Database = typeof db;
@@ -0,0 +1,73 @@
1
+ import { boolean, pgSchema, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
2
+
3
+ /**
4
+ * `auth` schema — mirrors the Better Auth tables defined in
5
+ * @nebutra/db's Prisma schema (auth.user / auth.session / auth.account /
6
+ * auth.verification). Drizzle reads from the same physical tables.
7
+ *
8
+ * Adding NEW columns here without a corresponding Prisma change is unsafe —
9
+ * Prisma's generator + RLS policies must stay in sync. Use this file as a
10
+ * read-mostly mirror until Drizzle becomes primary.
11
+ */
12
+ export const authSchema = pgSchema("auth");
13
+
14
+ export const users = authSchema.table(
15
+ "user",
16
+ {
17
+ id: text("id").primaryKey(),
18
+ name: text("name").notNull(),
19
+ email: text("email").notNull(),
20
+ emailVerified: boolean("email_verified").notNull().default(false),
21
+ image: text("image"),
22
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
23
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
24
+ },
25
+ (t) => ({
26
+ emailIdx: uniqueIndex("user_email_unique").on(t.email),
27
+ }),
28
+ );
29
+
30
+ export const sessions = authSchema.table("session", {
31
+ id: text("id").primaryKey(),
32
+ userId: text("user_id")
33
+ .notNull()
34
+ .references(() => users.id, { onDelete: "cascade" }),
35
+ token: text("token").notNull(),
36
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
37
+ ipAddress: text("ip_address"),
38
+ userAgent: text("user_agent"),
39
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
40
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
41
+ });
42
+
43
+ export const accounts = authSchema.table("account", {
44
+ id: text("id").primaryKey(),
45
+ userId: text("user_id")
46
+ .notNull()
47
+ .references(() => users.id, { onDelete: "cascade" }),
48
+ providerId: text("provider_id").notNull(),
49
+ accountId: text("account_id").notNull(),
50
+ accessToken: text("access_token"),
51
+ refreshToken: text("refresh_token"),
52
+ idToken: text("id_token"),
53
+ accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
54
+ refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
55
+ scope: text("scope"),
56
+ password: text("password"),
57
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
58
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
59
+ });
60
+
61
+ export const verifications = authSchema.table("verification", {
62
+ id: text("id").primaryKey(),
63
+ identifier: text("identifier").notNull(),
64
+ value: text("value").notNull(),
65
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
66
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
67
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
68
+ });
69
+
70
+ export type User = typeof users.$inferSelect;
71
+ export type NewUser = typeof users.$inferInsert;
72
+ export type Session = typeof sessions.$inferSelect;
73
+ export type Account = typeof accounts.$inferSelect;
@@ -0,0 +1,37 @@
1
+ import { integer, jsonb, numeric, pgTable, text, timestamp } from "drizzle-orm/pg-core";
2
+ import { organizations } from "./tenant";
3
+
4
+ /**
5
+ * Subscription + UsageLedger mirrors for the commerce/billing flow. The
6
+ * Prisma side owns writes; Drizzle is read-mostly for now.
7
+ */
8
+
9
+ export const subscriptions = pgTable("subscriptions", {
10
+ id: text("id").primaryKey(),
11
+ organizationId: text("organization_id")
12
+ .notNull()
13
+ .references(() => organizations.id, { onDelete: "cascade" }),
14
+ plan: text("plan").notNull(),
15
+ status: text("status").notNull(),
16
+ provider: text("provider").notNull(),
17
+ externalId: text("external_id"),
18
+ seats: integer("seats").notNull().default(1),
19
+ currentPeriodEnd: timestamp("current_period_end", { withTimezone: true }),
20
+ metadata: jsonb("metadata"),
21
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
22
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
23
+ });
24
+
25
+ export const usageLedger = pgTable("usage_ledger", {
26
+ id: text("id").primaryKey(),
27
+ organizationId: text("organization_id")
28
+ .notNull()
29
+ .references(() => organizations.id, { onDelete: "cascade" }),
30
+ meter: text("meter").notNull(),
31
+ quantity: numeric("quantity", { precision: 20, scale: 6 }).notNull(),
32
+ metadata: jsonb("metadata"),
33
+ recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull().defaultNow(),
34
+ });
35
+
36
+ export type Subscription = typeof subscriptions.$inferSelect;
37
+ export type UsageLedgerEntry = typeof usageLedger.$inferSelect;
@@ -0,0 +1,3 @@
1
+ export * from "./auth";
2
+ export * from "./billing";
3
+ export * from "./tenant";
@@ -0,0 +1,57 @@
1
+ import { pgSchema, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
2
+ import { users } from "./auth";
3
+
4
+ /**
5
+ * `auth.organization` + `auth.member` + `auth.invitation` — Better Auth's
6
+ * tenancy primitives. Mirrors the Prisma BAOrganization / BAMember /
7
+ * BAInvitation models.
8
+ */
9
+ export const orgSchema = pgSchema("auth");
10
+
11
+ export const organizations = orgSchema.table(
12
+ "organization",
13
+ {
14
+ id: text("id").primaryKey(),
15
+ name: text("name").notNull(),
16
+ slug: text("slug").notNull(),
17
+ logo: text("logo"),
18
+ metadata: text("metadata"),
19
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
20
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
21
+ },
22
+ (t) => ({
23
+ slugIdx: uniqueIndex("organization_slug_unique").on(t.slug),
24
+ }),
25
+ );
26
+
27
+ export const members = orgSchema.table("member", {
28
+ id: text("id").primaryKey(),
29
+ userId: text("user_id")
30
+ .notNull()
31
+ .references(() => users.id, { onDelete: "cascade" }),
32
+ organizationId: text("organization_id")
33
+ .notNull()
34
+ .references(() => organizations.id, { onDelete: "cascade" }),
35
+ role: text("role").notNull().default("member"),
36
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
37
+ });
38
+
39
+ export const invitations = orgSchema.table("invitation", {
40
+ id: text("id").primaryKey(),
41
+ email: text("email").notNull(),
42
+ inviterId: text("inviter_id").notNull(),
43
+ organizationId: text("organization_id")
44
+ .notNull()
45
+ .references(() => organizations.id, { onDelete: "cascade" }),
46
+ role: text("role"),
47
+ status: text("status").notNull().default("pending"),
48
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
49
+ token: text("token"),
50
+ acceptedAt: timestamp("accepted_at", { withTimezone: true }),
51
+ declinedAt: timestamp("declined_at", { withTimezone: true }),
52
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
53
+ });
54
+
55
+ export type Organization = typeof organizations.$inferSelect;
56
+ export type Member = typeof members.$inferSelect;
57
+ export type Invitation = typeof invitations.$inferSelect;
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler",
8
+ "target": "ES2022",
9
+ "strict": true,
10
+ "skipLibCheck": true,
11
+ "esModuleInterop": true,
12
+ "incremental": false
13
+ },
14
+ "include": ["src/**/*.ts", "drizzle.config.ts"],
15
+ "exclude": ["node_modules", "dist", "migrations"]
16
+ }
@@ -0,0 +1,8 @@
1
+ # tests
2
+
3
+ Cross-cutting tests that don't belong to a single package.
4
+
5
+ | Folder | Tool | Purpose |
6
+ |--------|------|---------|
7
+ | `architecture/` | vitest | Architecture rules — import boundaries, layering, no-cycle |
8
+ | `load/` | k6 | Load + perf tests against deployed environments |
@@ -0,0 +1,7 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ describe("architecture: package boundaries", () => {
4
+ it("apps do not import from each other", () => {
5
+ expect(true).toBe(true);
6
+ });
7
+ });
@@ -0,0 +1,13 @@
1
+ import { check, sleep } from "k6";
2
+ import http from "k6/http";
3
+
4
+ export const options = {
5
+ vus: 5,
6
+ duration: "30s",
7
+ };
8
+
9
+ export default function () {
10
+ const res = http.get(__ENV.BASE_URL ?? "http://localhost:3000");
11
+ check(res, { "status is 200": (r) => r.status === 200 });
12
+ sleep(1);
13
+ }
@@ -0,0 +1,9 @@
1
+ # workflows
2
+
3
+ Durable workflow & event-orchestration definitions for `{PRODUCT_NAME}`.
4
+
5
+ | Folder | Purpose |
6
+ |--------|---------|
7
+ | `inngest/` | Inngest functions (durable steps, cron, fan-out) |
8
+ | `n8n/` | n8n flow exports (low-code automations) |
9
+ | `pusher/` | Pusher Channels event maps for real-time fan-out |
@@ -0,0 +1,3 @@
1
+ # workflows/inngest
2
+
3
+ Inngest functions — durable steps, cron, fan-out. Register from `backends/gateway/`.
@@ -0,0 +1,14 @@
1
+ import { Inngest } from "inngest";
2
+
3
+ export const inngest = new Inngest({ id: "{PRODUCT_NAME}" });
4
+
5
+ export const welcomeEmail = inngest.createFunction(
6
+ { id: "welcome-email" },
7
+ { event: "user/created" },
8
+ async ({ event, step }) => {
9
+ await step.run("send-welcome", async () => {
10
+ // TODO: dispatch via @nebutra/email
11
+ return { to: event.data.email };
12
+ });
13
+ },
14
+ );
@@ -0,0 +1,3 @@
1
+ # workflows/n8n
2
+
3
+ n8n flow exports. Commit `.json` workflow files here and import them into your n8n instance.
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "{PRODUCT_NAME} — example flow",
3
+ "nodes": [],
4
+ "connections": {},
5
+ "active": false
6
+ }