@forgeailab/create-spark 0.1.1 → 0.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/package.json +7 -4
- package/packs/README.md +132 -0
- package/packs/ai-anthropic/files/app/api/ai/route.ts +57 -0
- package/packs/ai-anthropic/files/lib/anthropic.ts +15 -0
- package/packs/ai-anthropic/pack.toml +32 -0
- package/packs/ai-anthropic/skills/ai-feature-patterns/SKILL.md +87 -0
- package/packs/ai-anthropic/tasks.yaml +9 -0
- package/packs/ai-openai/files/app/api/ai-openai/route.ts +55 -0
- package/packs/ai-openai/files/lib/openai.ts +21 -0
- package/packs/ai-openai/pack.toml +30 -0
- package/packs/ai-openai/tasks.yaml +9 -0
- package/packs/analytics-posthog/files/components/PostHogProvider.tsx +19 -0
- package/packs/analytics-posthog/files/lib/posthog/client.ts +20 -0
- package/packs/analytics-posthog/files/lib/posthog/server.ts +24 -0
- package/packs/analytics-posthog/pack.toml +35 -0
- package/packs/analytics-posthog/tasks.yaml +15 -0
- package/packs/auth-better-auth/files/app/(auth)/login/page.tsx +58 -0
- package/packs/auth-better-auth/files/app/api/auth/[...all]/route.ts +4 -0
- package/packs/auth-better-auth/files/lib/auth.ts +21 -0
- package/packs/auth-better-auth/pack.toml +32 -0
- package/packs/auth-better-auth/tasks.yaml +10 -0
- package/packs/auth-better-auth-pg/files/app/api/auth/[...all]/route.ts +4 -0
- package/packs/auth-better-auth-pg/files/lib/auth.ts +86 -0
- package/packs/auth-better-auth-pg/pack.toml +32 -0
- package/packs/auth-better-auth-pg/tasks.yaml +17 -0
- package/packs/auth-supabase/files/app/(auth)/login/page.tsx +64 -0
- package/packs/auth-supabase/files/app/auth/callback/route.ts +15 -0
- package/packs/auth-supabase/files/middleware.ts +41 -0
- package/packs/auth-supabase/pack.toml +34 -0
- package/packs/auth-supabase/tasks.yaml +10 -0
- package/packs/db-postgres/files/compose/postgres.yml +28 -0
- package/packs/db-postgres/files/docker-compose.include.yml +1 -0
- package/packs/db-postgres/files/docker-compose.yml +6 -0
- package/packs/db-postgres/files/drizzle.config.ts +10 -0
- package/packs/db-postgres/files/lib/db/index.ts +10 -0
- package/packs/db-postgres/files/lib/db/schema.ts +11 -0
- package/packs/db-postgres/pack.toml +53 -0
- package/packs/db-postgres/tasks.yaml +11 -0
- package/packs/db-sqlite/files/drizzle.config.ts +10 -0
- package/packs/db-sqlite/files/lib/db.ts +8 -0
- package/packs/db-sqlite/files/lib/schema.ts +13 -0
- package/packs/db-sqlite/pack.toml +34 -0
- package/packs/db-sqlite/tasks.yaml +6 -0
- package/packs/db-supabase/files/lib/supabase/client.ts +8 -0
- package/packs/db-supabase/files/lib/supabase/server.ts +27 -0
- package/packs/db-supabase/pack.toml +32 -0
- package/packs/db-supabase/skills/supabase-patterns/SKILL.md +82 -0
- package/packs/db-supabase/tasks.yaml +6 -0
- package/packs/deploy-vercel/files/docs/deploy.md +21 -0
- package/packs/deploy-vercel/files/vercel.json +4 -0
- package/packs/deploy-vercel/pack.toml +30 -0
- package/packs/deploy-vercel/tasks.yaml +14 -0
- package/packs/docker-compose-dev/files/.env.docker.example +2 -0
- package/packs/docker-compose-dev/files/compose/redis.yml +17 -0
- package/packs/docker-compose-dev/files/docker-compose.include.yml +1 -0
- package/packs/docker-compose-dev/files/docker-compose.yml +6 -0
- package/packs/docker-compose-dev/pack.toml +38 -0
- package/packs/docker-compose-dev/tasks.yaml +9 -0
- package/packs/email-resend/files/app/api/email/test/route.ts +38 -0
- package/packs/email-resend/files/emails/welcome.tsx +66 -0
- package/packs/email-resend/files/lib/email.ts +40 -0
- package/packs/email-resend/pack.toml +34 -0
- package/packs/email-resend/tasks.yaml +9 -0
- package/packs/example/pack.toml +69 -0
- package/packs/payments-stripe/files/app/api/billing-portal/route.ts +24 -0
- package/packs/payments-stripe/files/app/api/checkout/route.ts +58 -0
- package/packs/payments-stripe/files/app/api/webhooks/stripe/route.ts +84 -0
- package/packs/payments-stripe/files/lib/stripe.ts +60 -0
- package/packs/payments-stripe/pack.toml +49 -0
- package/packs/payments-stripe/skills/stripe-patterns/SKILL.md +93 -0
- package/packs/payments-stripe/tasks.yaml +16 -0
- package/packs/sync-zero/files/components/ZeroProvider.tsx +3 -0
- package/packs/sync-zero/files/compose/zero-cache.yml +26 -0
- package/packs/sync-zero/files/docker-compose.include.yml +1 -0
- package/packs/sync-zero/files/docker-compose.yml +6 -0
- package/packs/sync-zero/files/lib/zero/client.ts +18 -0
- package/packs/sync-zero/files/lib/zero/schema.ts +17 -0
- package/packs/sync-zero/files/zero.config.ts +26 -0
- package/packs/sync-zero/pack.toml +61 -0
- package/packs/sync-zero/skills/zero-patterns/SKILL.md +69 -0
- package/packs/sync-zero/tasks.yaml +16 -0
- package/packs/testing-playwright/files/e2e/example.spec.ts +7 -0
- package/packs/testing-playwright/files/playwright.config.ts +33 -0
- package/packs/testing-playwright/pack.toml +25 -0
- package/packs/testing-playwright/tasks.yaml +9 -0
- package/packs/ui-shadcn/files/app/globals.css +56 -0
- package/packs/ui-shadcn/files/components/ui/button.tsx +47 -0
- package/packs/ui-shadcn/files/components/ui/card.tsx +33 -0
- package/packs/ui-shadcn/files/lib/utils.ts +6 -0
- package/packs/ui-shadcn/files/postcss.config.mjs +7 -0
- package/packs/ui-shadcn/files/tailwind.config.ts +57 -0
- package/packs/ui-shadcn/pack.toml +44 -0
- package/packs/ui-shadcn/skills/shadcn-dashboard-patterns/SKILL.md +85 -0
- package/packs/ui-shadcn/tasks.yaml +6 -0
- package/presets/docs-site.toml +4 -0
- package/presets/internal-tool.toml +4 -0
- package/presets/lean-saas.toml +4 -0
- package/presets/local-ai-mvp.toml +4 -0
- package/presets/saas-classic.toml +4 -0
- package/src/paths.ts +22 -4
- package/templates/README.md +43 -0
- package/templates/astro/README.md +3 -0
- package/templates/astro/template.toml +4 -0
- package/templates/astro-starlight/README.md +3 -0
- package/templates/astro-starlight/template.toml +4 -0
- package/templates/nextjs/.ai/architecture.md +13 -0
- package/templates/nextjs/.ai/board.md +7 -0
- package/templates/nextjs/.ai/product-spec.md +11 -0
- package/templates/nextjs/.claude/skills/.gitkeep +0 -0
- package/templates/nextjs/.codex/skills/.gitkeep +0 -0
- package/templates/nextjs/AGENTS.md +95 -0
- package/templates/nextjs/CLAUDE.md +3 -0
- package/templates/nextjs/README.md +20 -0
- package/templates/nextjs/anvil.config.json +4 -0
- package/templates/nextjs/app/(app)/home/page.tsx +43 -0
- package/templates/nextjs/app/(app)/home/posts-panel.tsx +83 -0
- package/templates/nextjs/app/(app)/layout.tsx +12 -0
- package/templates/nextjs/app/(auth)/login/page.tsx +97 -0
- package/templates/nextjs/app/globals.css +23 -0
- package/templates/nextjs/app/layout.tsx +20 -0
- package/templates/nextjs/app/page.tsx +39 -0
- package/templates/nextjs/lib/auth-placeholder.ts +21 -0
- package/templates/nextjs/lib/posts-placeholder.ts +30 -0
- package/templates/nextjs/next.config.ts +5 -0
- package/templates/nextjs/package.json +26 -0
- package/templates/nextjs/postcss.config.mjs +7 -0
- package/templates/nextjs/template.toml +4 -0
- package/templates/nextjs/tsconfig.json +27 -0
- package/templates/nextjs/types/post.ts +13 -0
- package/templates/one/README.md +5 -0
- package/templates/one/template.toml +4 -0
- package/templates/vite-react/README.md +3 -0
- package/templates/vite-react/template.toml +4 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createStripeClient,
|
|
3
|
+
createCheckoutSession as createStripeCheckoutSession,
|
|
4
|
+
createBillingPortalSession as createStripeBillingPortalSession,
|
|
5
|
+
verifyWebhookSignature as verifyStripeWebhookSignature,
|
|
6
|
+
} from '@forgeailab/spark-stripe-helpers';
|
|
7
|
+
|
|
8
|
+
function requireEnv(name: string): string {
|
|
9
|
+
const value = process.env[name];
|
|
10
|
+
if (!value) {
|
|
11
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
12
|
+
}
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const stripe = createStripeClient(requireEnv('STRIPE_SECRET_KEY'), {
|
|
17
|
+
appInfo: { name: 'spark payments-stripe' },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export function getStripePublishableKey(): string {
|
|
21
|
+
return requireEnv('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type CheckoutInput = {
|
|
25
|
+
origin: string;
|
|
26
|
+
priceId: string;
|
|
27
|
+
customerEmail?: string;
|
|
28
|
+
customerId?: string;
|
|
29
|
+
successUrl?: string;
|
|
30
|
+
cancelUrl?: string;
|
|
31
|
+
metadata?: Record<string, string>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export async function createCheckoutSession(input: CheckoutInput) {
|
|
35
|
+
return createStripeCheckoutSession(stripe, {
|
|
36
|
+
priceId: input.priceId,
|
|
37
|
+
customerId: input.customerId,
|
|
38
|
+
customerEmail: input.customerEmail,
|
|
39
|
+
successUrl:
|
|
40
|
+
input.successUrl ?? `${input.origin}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
41
|
+
cancelUrl: input.cancelUrl ?? `${input.origin}/billing`,
|
|
42
|
+
metadata: input.metadata,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type PortalInput = { customerId: string; returnUrl: string };
|
|
47
|
+
|
|
48
|
+
export async function createBillingPortalSession(input: PortalInput) {
|
|
49
|
+
return createStripeBillingPortalSession(stripe, input);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type WebhookInput = { payload: string; signature: string; secret?: string };
|
|
53
|
+
|
|
54
|
+
export function verifyWebhookSignature(input: WebhookInput) {
|
|
55
|
+
return verifyStripeWebhookSignature(stripe, {
|
|
56
|
+
payload: input.payload,
|
|
57
|
+
signatureHeader: input.signature,
|
|
58
|
+
secret: input.secret ?? requireEnv('STRIPE_WEBHOOK_SECRET'),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name = "payments-stripe"
|
|
2
|
+
version = "1.0.0"
|
|
3
|
+
category = "payments"
|
|
4
|
+
description = "Stripe Checkout, customer portal, and webhook handlers for subscriptions."
|
|
5
|
+
provides = ["payments"]
|
|
6
|
+
requires = ["db", "auth"]
|
|
7
|
+
conflicts = ["payments"]
|
|
8
|
+
requires_runtime = ["server"]
|
|
9
|
+
compatible_scaffolds = ["nextjs"]
|
|
10
|
+
|
|
11
|
+
[runtime_package]
|
|
12
|
+
package = "@forgeailab/spark-stripe-helpers"
|
|
13
|
+
version = "^0.1"
|
|
14
|
+
|
|
15
|
+
[dependencies]
|
|
16
|
+
runtime = ["@stripe/stripe-js"]
|
|
17
|
+
|
|
18
|
+
[env]
|
|
19
|
+
required = [
|
|
20
|
+
"STRIPE_SECRET_KEY",
|
|
21
|
+
"STRIPE_WEBHOOK_SECRET",
|
|
22
|
+
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[[files]]
|
|
26
|
+
mode = "create"
|
|
27
|
+
from = "lib/stripe.ts"
|
|
28
|
+
to = "lib/stripe.ts"
|
|
29
|
+
|
|
30
|
+
[[files]]
|
|
31
|
+
mode = "create"
|
|
32
|
+
from = "app/api/checkout/route.ts"
|
|
33
|
+
to = "app/api/checkout/route.ts"
|
|
34
|
+
|
|
35
|
+
[[files]]
|
|
36
|
+
mode = "create"
|
|
37
|
+
from = "app/api/webhooks/stripe/route.ts"
|
|
38
|
+
to = "app/api/webhooks/stripe/route.ts"
|
|
39
|
+
|
|
40
|
+
[[files]]
|
|
41
|
+
mode = "create"
|
|
42
|
+
from = "app/api/billing-portal/route.ts"
|
|
43
|
+
to = "app/api/billing-portal/route.ts"
|
|
44
|
+
|
|
45
|
+
[skills]
|
|
46
|
+
copy = ["skills/stripe-patterns"]
|
|
47
|
+
|
|
48
|
+
[tasks]
|
|
49
|
+
file = "tasks.yaml"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stripe-patterns
|
|
3
|
+
description: Build Stripe subscription flows with Checkout, Billing Portal, and signed webhooks. Use when implementing or reviewing billing behavior after the payments-stripe pack is installed.
|
|
4
|
+
allowed-tools:
|
|
5
|
+
- Read
|
|
6
|
+
- Write
|
|
7
|
+
- Edit
|
|
8
|
+
- Bash
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Skill: stripe-patterns
|
|
12
|
+
|
|
13
|
+
## Goal
|
|
14
|
+
|
|
15
|
+
Ship a small, reliable subscription billing slice: Checkout creates the initial
|
|
16
|
+
subscription, Billing Portal handles plan and payment-method changes, and
|
|
17
|
+
webhooks update the app database as the billing source of truth changes.
|
|
18
|
+
|
|
19
|
+
## Recommended model
|
|
20
|
+
|
|
21
|
+
Opus 4.7 or GPT-5.5 for pricing and lifecycle decisions. Sonnet 4.6 or GPT-5
|
|
22
|
+
family executor for routine route and UI wiring.
|
|
23
|
+
|
|
24
|
+
## Inputs
|
|
25
|
+
|
|
26
|
+
Read these before changing billing code:
|
|
27
|
+
|
|
28
|
+
- `.ai/product-spec.md` for the monetization promise and non-goals
|
|
29
|
+
- `.ai/architecture.md` for the chosen auth and database providers
|
|
30
|
+
- `.ai/board.md` for the exact billing task and acceptance criteria
|
|
31
|
+
- `lib/stripe.ts` for shared Stripe client setup
|
|
32
|
+
- `app/api/webhooks/stripe/route.ts` for event handling
|
|
33
|
+
|
|
34
|
+
If the spec does not say what users buy or what paid access unlocks, stop and
|
|
35
|
+
ask. Do not invent pricing rules inside implementation.
|
|
36
|
+
|
|
37
|
+
## Subscription Model
|
|
38
|
+
|
|
39
|
+
- Treat Stripe as the source of truth for payment state.
|
|
40
|
+
- Treat the app database as a query cache for product access decisions.
|
|
41
|
+
- Store Stripe customer id on the app user or organization record.
|
|
42
|
+
- Store subscription id, price id, status, current period dates, and cancel flag.
|
|
43
|
+
- Grant access from database state, not from client-side checkout responses.
|
|
44
|
+
- Prefer Stripe Checkout for first purchase and Billing Portal for later changes.
|
|
45
|
+
- Keep product and price creation in the Stripe dashboard unless the board says
|
|
46
|
+
the product catalog must be managed in-app.
|
|
47
|
+
|
|
48
|
+
## Webhook Rules
|
|
49
|
+
|
|
50
|
+
- Verify signatures with `STRIPE_WEBHOOK_SECRET` and the raw request body.
|
|
51
|
+
- Process webhooks idempotently by storing Stripe event ids in the database.
|
|
52
|
+
- Use an atomic insert or unique index for event id de-duplication.
|
|
53
|
+
- Acknowledge duplicate events with success, not an error.
|
|
54
|
+
- Update access on `checkout.session.completed`,
|
|
55
|
+
`customer.subscription.updated`, and `customer.subscription.deleted`.
|
|
56
|
+
- Add invoice event handling only when the product spec needs grace periods,
|
|
57
|
+
failed payment messaging, or invoice history.
|
|
58
|
+
|
|
59
|
+
## Checkout Rules
|
|
60
|
+
|
|
61
|
+
- Never accept price amounts from the browser.
|
|
62
|
+
- Accept only Stripe price ids that the server has allowlisted or loaded from
|
|
63
|
+
trusted configuration.
|
|
64
|
+
- Create or reuse exactly one Stripe customer for each billable account.
|
|
65
|
+
- Send stable metadata, such as user id or organization id, on Checkout Sessions.
|
|
66
|
+
- Redirect only to same-origin success and cancel URLs unless explicitly needed.
|
|
67
|
+
|
|
68
|
+
## Customer Portal Rules
|
|
69
|
+
|
|
70
|
+
- Require an authenticated user before creating a portal session.
|
|
71
|
+
- Resolve the Stripe customer id from the database, not from request body trust.
|
|
72
|
+
- Use the portal for payment method changes, cancellations, and plan changes
|
|
73
|
+
unless custom billing UX is required by the board.
|
|
74
|
+
|
|
75
|
+
## Common Pitfalls
|
|
76
|
+
|
|
77
|
+
- Do not mark a user paid immediately after `checkout.sessions.create`.
|
|
78
|
+
- Do not parse JSON before webhook signature verification.
|
|
79
|
+
- Do not skip webhook idempotency because Stripe retries events.
|
|
80
|
+
- Do not trust `customerEmail` as identity; it is only a checkout convenience.
|
|
81
|
+
- Do not create a new customer on every checkout attempt.
|
|
82
|
+
- Do not build custom plan management before Billing Portal is exhausted.
|
|
83
|
+
|
|
84
|
+
## Verification
|
|
85
|
+
|
|
86
|
+
Use Stripe CLI forwarding for local webhook tests:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
stripe listen --forward-to localhost:3000/api/webhooks/stripe
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Then create a Checkout Session, complete the test payment, and confirm the app
|
|
93
|
+
database changes only after the signed webhook is processed.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
epic: Payments
|
|
2
|
+
|
|
3
|
+
tasks:
|
|
4
|
+
- id: PAY-001
|
|
5
|
+
title: Configure products + prices in Stripe dashboard
|
|
6
|
+
status: Clarifying
|
|
7
|
+
acceptance:
|
|
8
|
+
- Stripe dashboard has at least one active subscription product and recurring price.
|
|
9
|
+
- The selected price ID is available to the checkout flow.
|
|
10
|
+
|
|
11
|
+
- id: PAY-002
|
|
12
|
+
title: Test webhook signing locally with stripe listen
|
|
13
|
+
status: Clarifying
|
|
14
|
+
acceptance:
|
|
15
|
+
- Local webhook forwarding reaches app/api/webhooks/stripe.
|
|
16
|
+
- A signed test event is accepted and unsigned payloads are rejected.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
services:
|
|
2
|
+
zero-cache:
|
|
3
|
+
image: rocicorp/zero:latest
|
|
4
|
+
restart: unless-stopped
|
|
5
|
+
environment:
|
|
6
|
+
ZERO_UPSTREAM_DB: ${ZERO_UPSTREAM_DB}
|
|
7
|
+
ZERO_CVR_DB: ${ZERO_CVR_DB:-${ZERO_UPSTREAM_DB}_cvr}
|
|
8
|
+
ZERO_CHANGE_DB: ${ZERO_CHANGE_DB:-${ZERO_UPSTREAM_DB}_change}
|
|
9
|
+
ZERO_REPLICA_FILE: /zero/replica.db
|
|
10
|
+
ZERO_AUTH_SECRET: ${ZERO_AUTH_SECRET}
|
|
11
|
+
ZERO_ADMIN_PASSWORD: ${ZERO_ADMIN_PASSWORD:-change-me}
|
|
12
|
+
ZERO_PUSH_URL: ${ZERO_PUSH_URL:-http://host.docker.internal:3000/api/zero/mutate}
|
|
13
|
+
ZERO_LOG_LEVEL: ${ZERO_LOG_LEVEL:-info}
|
|
14
|
+
ZERO_NUM_SYNC_WORKERS: "1"
|
|
15
|
+
ports:
|
|
16
|
+
- "${ZERO_PORT:-4848}:4848"
|
|
17
|
+
volumes:
|
|
18
|
+
- zero_data:/zero
|
|
19
|
+
extra_hosts:
|
|
20
|
+
- "host.docker.internal:host-gateway"
|
|
21
|
+
depends_on:
|
|
22
|
+
postgres:
|
|
23
|
+
condition: service_healthy
|
|
24
|
+
|
|
25
|
+
volumes:
|
|
26
|
+
zero_data:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- compose/zero-cache.yml
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createZeroClient as createSparkZeroClient } from '@forgeailab/spark-sync-zero';
|
|
4
|
+
import type { ZeroOptions } from '@forgeailab/spark-sync-zero';
|
|
5
|
+
import { schema } from './schema';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_ZERO_URL = 'http://localhost:4848';
|
|
8
|
+
|
|
9
|
+
export function createZeroOptions(): ZeroOptions {
|
|
10
|
+
return {
|
|
11
|
+
cacheURL: process.env.NEXT_PUBLIC_ZERO_URL ?? DEFAULT_ZERO_URL,
|
|
12
|
+
schema,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createZeroClient(options: ZeroOptions = createZeroOptions()) {
|
|
17
|
+
return createSparkZeroClient(options);
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineZeroSchema, number, string, table } from '@forgeailab/spark-sync-zero';
|
|
2
|
+
|
|
3
|
+
// Example schema. Replace with your app's tables.
|
|
4
|
+
const users = table('user')
|
|
5
|
+
.columns({
|
|
6
|
+
id: string(),
|
|
7
|
+
name: string(),
|
|
8
|
+
email: string(),
|
|
9
|
+
createdAt: number(),
|
|
10
|
+
})
|
|
11
|
+
.primaryKey('id');
|
|
12
|
+
|
|
13
|
+
export const { schema, zql } = defineZeroSchema({
|
|
14
|
+
tables: [users],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export type Schema = typeof schema;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type ZeroCacheConfig = {
|
|
2
|
+
upstreamDB: string;
|
|
3
|
+
authSecret: string;
|
|
4
|
+
queryURL: string;
|
|
5
|
+
mutateURL: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function getZeroCacheConfig(): ZeroCacheConfig {
|
|
9
|
+
const upstreamDB = process.env.ZERO_UPSTREAM_DB;
|
|
10
|
+
const authSecret = process.env.ZERO_AUTH_SECRET;
|
|
11
|
+
|
|
12
|
+
if (!upstreamDB) {
|
|
13
|
+
throw new Error("ZERO_UPSTREAM_DB is required to run zero-cache.");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!authSecret) {
|
|
17
|
+
throw new Error("ZERO_AUTH_SECRET is required to authenticate Zero clients.");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
upstreamDB,
|
|
22
|
+
authSecret,
|
|
23
|
+
queryURL: "http://localhost:3000/api/query",
|
|
24
|
+
mutateURL: "http://localhost:3000/api/mutate",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name = "sync-zero"
|
|
2
|
+
version = "1.0.0"
|
|
3
|
+
category = "infra"
|
|
4
|
+
description = "Rocicorp Zero client-first sync setup for a Postgres-backed app."
|
|
5
|
+
provides = ["sync"]
|
|
6
|
+
requires = ["db-pg"]
|
|
7
|
+
conflicts = ["sync"]
|
|
8
|
+
requires_runtime = ["server"]
|
|
9
|
+
compatible_scaffolds = ["nextjs"]
|
|
10
|
+
|
|
11
|
+
[runtime_package]
|
|
12
|
+
package = "@forgeailab/spark-sync-zero"
|
|
13
|
+
version = "^0.1"
|
|
14
|
+
|
|
15
|
+
[dependencies]
|
|
16
|
+
runtime = ["@rocicorp/zero"]
|
|
17
|
+
|
|
18
|
+
[env]
|
|
19
|
+
required = ["ZERO_AUTH_SECRET", "ZERO_UPSTREAM_DB"]
|
|
20
|
+
optional = []
|
|
21
|
+
|
|
22
|
+
[[files]]
|
|
23
|
+
mode = "create"
|
|
24
|
+
from = "zero.config.ts"
|
|
25
|
+
to = "zero.config.ts"
|
|
26
|
+
|
|
27
|
+
[[files]]
|
|
28
|
+
mode = "create"
|
|
29
|
+
from = "lib/zero/schema.ts"
|
|
30
|
+
to = "lib/zero/schema.ts"
|
|
31
|
+
|
|
32
|
+
[[files]]
|
|
33
|
+
mode = "create"
|
|
34
|
+
from = "lib/zero/client.ts"
|
|
35
|
+
to = "lib/zero/client.ts"
|
|
36
|
+
|
|
37
|
+
[[files]]
|
|
38
|
+
mode = "create"
|
|
39
|
+
from = "components/ZeroProvider.tsx"
|
|
40
|
+
to = "components/ZeroProvider.tsx"
|
|
41
|
+
|
|
42
|
+
[[files]]
|
|
43
|
+
mode = "create-or-skip"
|
|
44
|
+
from = "docker-compose.yml"
|
|
45
|
+
to = "docker-compose.yml"
|
|
46
|
+
|
|
47
|
+
[[files]]
|
|
48
|
+
mode = "create"
|
|
49
|
+
from = "compose/zero-cache.yml"
|
|
50
|
+
to = "compose/zero-cache.yml"
|
|
51
|
+
|
|
52
|
+
[[files]]
|
|
53
|
+
mode = "append"
|
|
54
|
+
from = "docker-compose.include.yml"
|
|
55
|
+
to = "docker-compose.yml"
|
|
56
|
+
|
|
57
|
+
[skills]
|
|
58
|
+
copy = ["skills/zero-patterns"]
|
|
59
|
+
|
|
60
|
+
[tasks]
|
|
61
|
+
file = "tasks.yaml"
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: zero-patterns
|
|
3
|
+
description: Use when implementing Rocicorp Zero client-first sync, schema changes, queries, mutators, or zero-cache setup in an spark project.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Zero Patterns
|
|
7
|
+
|
|
8
|
+
## Core Model
|
|
9
|
+
|
|
10
|
+
Zero is client-first. Reads should feel local because the client queries its
|
|
11
|
+
local store first, then syncs with `zero-cache` and the upstream Postgres
|
|
12
|
+
database in the background.
|
|
13
|
+
|
|
14
|
+
Do not treat Zero as a REST wrapper. Keep query definitions and mutators as
|
|
15
|
+
the product contract. Components consume those contracts through Zero React
|
|
16
|
+
helpers instead of hand-building fetch calls for synced data.
|
|
17
|
+
|
|
18
|
+
## Before Coding
|
|
19
|
+
|
|
20
|
+
1. Check which `db` pack is installed.
|
|
21
|
+
2. Find the authoritative database schema and migrations.
|
|
22
|
+
3. Read `lib/zero/schema.ts`.
|
|
23
|
+
4. Identify the smallest set of rows the UI needs.
|
|
24
|
+
5. Confirm auth context before exposing user-scoped data.
|
|
25
|
+
|
|
26
|
+
## Schema Authoring
|
|
27
|
+
|
|
28
|
+
Zero schema mirrors the subset of Postgres that clients can query. Keep it
|
|
29
|
+
small, explicit, and aligned with the database.
|
|
30
|
+
|
|
31
|
+
Use table builders from `@rocicorp/zero`, for example `table`, `string`,
|
|
32
|
+
`boolean`, `number`, `json`, and `enumeration`.
|
|
33
|
+
|
|
34
|
+
Every table must have an explicit primary key. Prefer stable string IDs for
|
|
35
|
+
client-created records. Avoid auto-increment IDs for records created from the
|
|
36
|
+
client because mutators may run more than once.
|
|
37
|
+
|
|
38
|
+
When adding fields, use an expand deploy order: database first, then server
|
|
39
|
+
query or mutate code, then client usage. When removing fields, reverse that:
|
|
40
|
+
client stops using it, then server stops exposing it, then database removes it.
|
|
41
|
+
|
|
42
|
+
## Queries
|
|
43
|
+
|
|
44
|
+
Clients should call named query helpers, not arbitrary server endpoints. Keep
|
|
45
|
+
queries narrow enough that Zero can cache and update them efficiently.
|
|
46
|
+
|
|
47
|
+
## Mutators
|
|
48
|
+
|
|
49
|
+
Mutators are optimistic. They can run on the client and again on the server, so
|
|
50
|
+
they must be deterministic and safe to replay.
|
|
51
|
+
|
|
52
|
+
Generate IDs before calling the mutator and pass them as arguments. Do not
|
|
53
|
+
generate random IDs, timestamps, or external side effects inside a mutator.
|
|
54
|
+
|
|
55
|
+
Validate mutator arguments at the boundary. Keep authorization checks on the
|
|
56
|
+
server path, even if the client path also hides unauthorized actions.
|
|
57
|
+
|
|
58
|
+
## Local Development
|
|
59
|
+
|
|
60
|
+
`zero-cache` needs a Postgres upstream with logical replication enabled. Keep
|
|
61
|
+
`ZERO_UPSTREAM_DB` pointed at the same database your app server uses.
|
|
62
|
+
|
|
63
|
+
## Review Checklist
|
|
64
|
+
|
|
65
|
+
- Schema matches current migrations.
|
|
66
|
+
- Client-visible rows are scoped by query and auth context.
|
|
67
|
+
- Mutators are deterministic and replay-safe.
|
|
68
|
+
- Query shape is indexed or intentionally small.
|
|
69
|
+
- Local setup documents `ZERO_UPSTREAM_DB` and cache reset steps.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
epic: Sync
|
|
2
|
+
tasks:
|
|
3
|
+
- id: ZERO-001
|
|
4
|
+
title: Define initial schema in lib/zero/schema.ts
|
|
5
|
+
status: Clarifying
|
|
6
|
+
acceptance:
|
|
7
|
+
- The Zero schema matches the first database tables that need client-first reads.
|
|
8
|
+
- Every synced table has an explicit primary key and only includes fields needed by the client.
|
|
9
|
+
- Schema changes are deployed in Zero-safe expand or contract order.
|
|
10
|
+
- id: ZERO-002
|
|
11
|
+
title: Run zero-cache locally
|
|
12
|
+
status: Clarifying
|
|
13
|
+
acceptance:
|
|
14
|
+
- zero-cache starts with ZERO_UPSTREAM_DB pointed at a logical-replication-enabled Postgres database.
|
|
15
|
+
- Local app query and mutate endpoints are reachable from zero-cache.
|
|
16
|
+
- The development workflow documents how to reset the local SQLite replica.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
4
|
+
const baseURL = `http://127.0.0.1:${port}`;
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
testDir: "./e2e",
|
|
8
|
+
fullyParallel: true,
|
|
9
|
+
timeout: 30_000,
|
|
10
|
+
expect: {
|
|
11
|
+
timeout: 5_000,
|
|
12
|
+
},
|
|
13
|
+
reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list",
|
|
14
|
+
use: {
|
|
15
|
+
baseURL,
|
|
16
|
+
trace: "on-first-retry",
|
|
17
|
+
},
|
|
18
|
+
webServer: {
|
|
19
|
+
command: "bun dev",
|
|
20
|
+
url: baseURL,
|
|
21
|
+
reuseExistingServer: !process.env.CI,
|
|
22
|
+
timeout: 120_000,
|
|
23
|
+
env: {
|
|
24
|
+
PORT: String(port),
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
projects: [
|
|
28
|
+
{
|
|
29
|
+
name: "chromium",
|
|
30
|
+
use: { ...devices["Desktop Chrome"] },
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name = "testing-playwright"
|
|
2
|
+
version = "1.0.0"
|
|
3
|
+
category = "testing"
|
|
4
|
+
description = "Playwright end-to-end test setup for the Next.js scaffold."
|
|
5
|
+
provides = ["e2e"]
|
|
6
|
+
requires = []
|
|
7
|
+
conflicts = []
|
|
8
|
+
requires_runtime = ["server"]
|
|
9
|
+
compatible_scaffolds = ["nextjs"]
|
|
10
|
+
|
|
11
|
+
[dependencies]
|
|
12
|
+
dev = ["@playwright/test"]
|
|
13
|
+
|
|
14
|
+
[[files]]
|
|
15
|
+
mode = "create"
|
|
16
|
+
from = "playwright.config.ts"
|
|
17
|
+
to = "playwright.config.ts"
|
|
18
|
+
|
|
19
|
+
[[files]]
|
|
20
|
+
mode = "create"
|
|
21
|
+
from = "e2e/example.spec.ts"
|
|
22
|
+
to = "e2e/example.spec.ts"
|
|
23
|
+
|
|
24
|
+
[tasks]
|
|
25
|
+
file = "tasks.yaml"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
epic: Testing
|
|
2
|
+
|
|
3
|
+
tasks:
|
|
4
|
+
- id: E2E-001
|
|
5
|
+
title: Add a smoke test for the first user-facing flow
|
|
6
|
+
status: Clarifying
|
|
7
|
+
acceptance:
|
|
8
|
+
- The first user-facing flow has a Playwright smoke test.
|
|
9
|
+
- The test runs against the local dev server through Playwright config.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* # >>> spark:ui-shadcn >>> */
|
|
2
|
+
@layer base {
|
|
3
|
+
:root {
|
|
4
|
+
--background: 0 0% 100%;
|
|
5
|
+
--foreground: 222.2 84% 4.9%;
|
|
6
|
+
--card: 0 0% 100%;
|
|
7
|
+
--card-foreground: 222.2 84% 4.9%;
|
|
8
|
+
--popover: 0 0% 100%;
|
|
9
|
+
--popover-foreground: 222.2 84% 4.9%;
|
|
10
|
+
--primary: 221.2 83.2% 53.3%;
|
|
11
|
+
--primary-foreground: 210 40% 98%;
|
|
12
|
+
--secondary: 210 40% 96.1%;
|
|
13
|
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
14
|
+
--muted: 210 40% 96.1%;
|
|
15
|
+
--muted-foreground: 215.4 16.3% 46.9%;
|
|
16
|
+
--accent: 210 40% 96.1%;
|
|
17
|
+
--accent-foreground: 222.2 47.4% 11.2%;
|
|
18
|
+
--destructive: 0 84.2% 60.2%;
|
|
19
|
+
--destructive-foreground: 210 40% 98%;
|
|
20
|
+
--border: 214.3 31.8% 91.4%;
|
|
21
|
+
--input: 214.3 31.8% 91.4%;
|
|
22
|
+
--ring: 221.2 83.2% 53.3%;
|
|
23
|
+
--radius: 0.5rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.dark {
|
|
27
|
+
--background: 222.2 84% 4.9%;
|
|
28
|
+
--foreground: 210 40% 98%;
|
|
29
|
+
--card: 222.2 84% 4.9%;
|
|
30
|
+
--card-foreground: 210 40% 98%;
|
|
31
|
+
--popover: 222.2 84% 4.9%;
|
|
32
|
+
--popover-foreground: 210 40% 98%;
|
|
33
|
+
--primary: 217.2 91.2% 59.8%;
|
|
34
|
+
--primary-foreground: 222.2 47.4% 11.2%;
|
|
35
|
+
--secondary: 217.2 32.6% 17.5%;
|
|
36
|
+
--secondary-foreground: 210 40% 98%;
|
|
37
|
+
--muted: 217.2 32.6% 17.5%;
|
|
38
|
+
--muted-foreground: 215 20.2% 65.1%;
|
|
39
|
+
--accent: 217.2 32.6% 17.5%;
|
|
40
|
+
--accent-foreground: 210 40% 98%;
|
|
41
|
+
--destructive: 0 62.8% 30.6%;
|
|
42
|
+
--destructive-foreground: 210 40% 98%;
|
|
43
|
+
--border: 217.2 32.6% 17.5%;
|
|
44
|
+
--input: 217.2 32.6% 17.5%;
|
|
45
|
+
--ring: 224.3 76.3% 48%;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
* {
|
|
49
|
+
@apply border-border;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
body {
|
|
53
|
+
@apply bg-background text-foreground;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/* # <<< spark:ui-shadcn <<< */
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
'inline-flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
12
|
+
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
|
13
|
+
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
|
14
|
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
15
|
+
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
16
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
17
|
+
},
|
|
18
|
+
size: {
|
|
19
|
+
default: 'h-10 px-4 py-2',
|
|
20
|
+
sm: 'h-9 rounded-md px-3',
|
|
21
|
+
lg: 'h-11 rounded-md px-8',
|
|
22
|
+
icon: 'h-10 w-10',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: {
|
|
26
|
+
variant: 'default',
|
|
27
|
+
size: 'default',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export function Button({
|
|
33
|
+
className,
|
|
34
|
+
variant,
|
|
35
|
+
size,
|
|
36
|
+
asChild = false,
|
|
37
|
+
...props
|
|
38
|
+
}: React.ComponentProps<'button'> &
|
|
39
|
+
VariantProps<typeof buttonVariants> & {
|
|
40
|
+
asChild?: boolean;
|
|
41
|
+
}) {
|
|
42
|
+
const Comp = asChild ? Slot : 'button';
|
|
43
|
+
|
|
44
|
+
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export { buttonVariants };
|