ras-stack 0.39.5 → 0.40.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 (75) hide show
  1. package/README.md +17 -8
  2. package/dist/auth/settings.d.ts +1 -1
  3. package/dist/auth/settings.js +1 -1
  4. package/dist/auth/settings.js.map +1 -1
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +3 -1
  7. package/dist/cli.js.map +1 -1
  8. package/dist/create/index.d.ts +1 -0
  9. package/dist/create/index.js +69 -0
  10. package/dist/create/index.js.map +1 -0
  11. package/dist/runtime/dev.js +1 -1
  12. package/dist/runtime/dev.js.map +1 -1
  13. package/dist/runtime/index.js +77 -13
  14. package/dist/runtime/index.js.map +1 -1
  15. package/examples/full-stack/.env.example +16 -0
  16. package/examples/full-stack/.oxfmtrc.json +7 -0
  17. package/examples/full-stack/Dockerfile +37 -0
  18. package/examples/full-stack/Dockerfile.standalone +28 -0
  19. package/examples/full-stack/centrifugo.json +14 -0
  20. package/examples/full-stack/dockerignore.template +8 -0
  21. package/examples/full-stack/drizzle/0000_production_reference.sql +114 -0
  22. package/examples/full-stack/drizzle/meta/_journal.json +13 -0
  23. package/examples/full-stack/e2e/full-stack.spec.ts +45 -0
  24. package/examples/full-stack/gitignore.template +9 -0
  25. package/examples/full-stack/oxlint.json +4 -0
  26. package/examples/full-stack/package.json +58 -0
  27. package/examples/full-stack/playwright.config.ts +7 -0
  28. package/examples/full-stack/pnpm-workspace.template.yaml +12 -0
  29. package/examples/full-stack/ras-stack.assets.json +4 -0
  30. package/examples/full-stack/scripts/containerRuntime.ts +29 -0
  31. package/examples/full-stack/scripts/database.test.ts +121 -0
  32. package/examples/full-stack/scripts/database.ts +105 -0
  33. package/examples/full-stack/src/client/auth.ts +3 -0
  34. package/examples/full-stack/src/client/queries.ts +4 -0
  35. package/examples/full-stack/src/client/queryClient.ts +1 -0
  36. package/examples/full-stack/src/client/useRealtime.ts +20 -0
  37. package/examples/full-stack/src/posthog.ts +17 -0
  38. package/examples/full-stack/src/routeTree.gen.ts +230 -0
  39. package/examples/full-stack/src/router.tsx +17 -0
  40. package/examples/full-stack/src/routes/__root.tsx +38 -0
  41. package/examples/full-stack/src/routes/api/auth.$.ts +8 -0
  42. package/examples/full-stack/src/routes/api/centrifugo.connect.ts +29 -0
  43. package/examples/full-stack/src/routes/api/health.ts +6 -0
  44. package/examples/full-stack/src/routes/api/live.ts +5 -0
  45. package/examples/full-stack/src/routes/api/ready.ts +30 -0
  46. package/examples/full-stack/src/routes/api/uploads.$id.ts +40 -0
  47. package/examples/full-stack/src/routes/api/uploads.ts +25 -0
  48. package/examples/full-stack/src/routes/index.tsx +187 -0
  49. package/examples/full-stack/src/server/app.test.ts +24 -0
  50. package/examples/full-stack/src/server/app.ts +172 -0
  51. package/examples/full-stack/src/server/auth-flow.test.ts +148 -0
  52. package/examples/full-stack/src/server/auth.ts +60 -0
  53. package/examples/full-stack/src/server/environment.test.ts +43 -0
  54. package/examples/full-stack/src/server/environment.ts +67 -0
  55. package/examples/full-stack/src/server/fns.ts +38 -0
  56. package/examples/full-stack/src/server/messages.test.ts +38 -0
  57. package/examples/full-stack/src/server/messages.ts +28 -0
  58. package/examples/full-stack/src/server/migration.test.ts +34 -0
  59. package/examples/full-stack/src/server/outbox.test.ts +130 -0
  60. package/examples/full-stack/src/server/outbox.ts +110 -0
  61. package/examples/full-stack/src/server/posthog.test.ts +30 -0
  62. package/examples/full-stack/src/server/rate-limit.test.ts +32 -0
  63. package/examples/full-stack/src/server/rate-limit.ts +29 -0
  64. package/examples/full-stack/src/server/rpc.ts +16 -0
  65. package/examples/full-stack/src/server/schema.ts +134 -0
  66. package/examples/full-stack/src/server/session.test.ts +56 -0
  67. package/examples/full-stack/src/server/session.ts +13 -0
  68. package/examples/full-stack/src/server/uploads.test.ts +142 -0
  69. package/examples/full-stack/src/server/uploads.ts +199 -0
  70. package/examples/full-stack/src/start.ts +12 -0
  71. package/examples/full-stack/src/styles.css +42 -0
  72. package/examples/full-stack/tsconfig.json +8 -0
  73. package/examples/full-stack/vite.config.ts +31 -0
  74. package/examples/full-stack/vitest.config.ts +3 -0
  75. package/package.json +10 -8
@@ -0,0 +1,28 @@
1
+ # syntax=docker/dockerfile:1
2
+ ARG RUNTIME_BINARIES_IMAGE=ghcr.io/richardsolomou/ras-stack-runtime-binaries:runtime-v1.0.1@sha256:581a691b59a603685bee8ba9576e80d419b967b63017f82d778fce39ca6f0c0b
3
+ FROM node:24-alpine AS build
4
+ WORKDIR /app
5
+ RUN apk add --no-cache python3 make g++
6
+ RUN corepack enable && corepack install --global pnpm@11.15.0
7
+ COPY . .
8
+ RUN pnpm install --no-frozen-lockfile
9
+ RUN pnpm build
10
+
11
+ FROM ${RUNTIME_BINARIES_IMAGE} AS runtime-binaries
12
+
13
+ FROM node:24-alpine
14
+ WORKDIR /app
15
+ RUN apk add --no-cache tini \
16
+ && rm -rf /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/npm /opt/yarn-v1.22.22 \
17
+ && rm -f /usr/local/bin/corepack /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/yarn /usr/local/bin/yarnpkg \
18
+ && mkdir -p /data \
19
+ && chown -R node:node /app /data
20
+ COPY --from=build --chown=node:node /app/.output ./.output
21
+ COPY --from=runtime-binaries /usr/local/bin/centrifugo /usr/local/bin/centrifugo
22
+ COPY --from=runtime-binaries /usr/local/bin/caddy /usr/local/bin/caddy
23
+ COPY --chown=node:node centrifugo.json ./centrifugo.json
24
+ ENV NODE_ENV=production DATA_DIR=/data TRUST_PROXY=false CENTRIFUGO_CONFIG=/app/centrifugo.json CENTRIFUGO_API_URL=http://127.0.0.1:8100/api CENTRIFUGO_HTTP_SERVER_PORT=8100
25
+ EXPOSE 3100
26
+ HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:3100/api/ready || exit 1
27
+ USER node
28
+ ENTRYPOINT ["/sbin/tini", "-g", "--", "node", ".output/server/containerRuntime.mjs"]
@@ -0,0 +1,14 @@
1
+ {
2
+ "client": {
3
+ "proxy": {
4
+ "connect": {
5
+ "enabled": true,
6
+ "endpoint": "http://127.0.0.1:3101/api/centrifugo/connect",
7
+ "http_headers": ["Cookie", "Origin", "X-Forwarded-Host", "X-Forwarded-Proto"],
8
+ "http": { "static_headers": { "X-Proxy-Secret": "${CENTRIFUGO_VAR_PROXY_SECRET}" } }
9
+ }
10
+ }
11
+ },
12
+ "channel": { "namespaces": [{ "name": "messages", "allow_subscribe_for_client": true }] },
13
+ "health": { "enabled": true }
14
+ }
@@ -0,0 +1,8 @@
1
+ .data
2
+ .git
3
+ .output
4
+ node_modules
5
+ test-results
6
+ .env
7
+ .env.*
8
+ !.env.example
@@ -0,0 +1,114 @@
1
+ CREATE TABLE `user` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `name` text NOT NULL,
4
+ `email` text NOT NULL,
5
+ `email_verified` integer DEFAULT false NOT NULL,
6
+ `image` text,
7
+ `created_at` integer NOT NULL,
8
+ `updated_at` integer NOT NULL
9
+ );
10
+ --> statement-breakpoint
11
+ CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);
12
+ --> statement-breakpoint
13
+ CREATE TABLE `session` (
14
+ `id` text PRIMARY KEY NOT NULL,
15
+ `expires_at` integer NOT NULL,
16
+ `token` text NOT NULL,
17
+ `created_at` integer NOT NULL,
18
+ `updated_at` integer NOT NULL,
19
+ `ip_address` text,
20
+ `user_agent` text,
21
+ `user_id` text NOT NULL,
22
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
23
+ );
24
+ --> statement-breakpoint
25
+ CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);
26
+ --> statement-breakpoint
27
+ CREATE INDEX `session_user_id_idx` ON `session` (`user_id`);
28
+ --> statement-breakpoint
29
+ CREATE TABLE `account` (
30
+ `id` text PRIMARY KEY NOT NULL,
31
+ `account_id` text NOT NULL,
32
+ `issuer` text NOT NULL,
33
+ `provider_id` text NOT NULL,
34
+ `user_id` text NOT NULL,
35
+ `access_token` text,
36
+ `refresh_token` text,
37
+ `id_token` text,
38
+ `access_token_expires_at` integer,
39
+ `refresh_token_expires_at` integer,
40
+ `scope` text,
41
+ `password` text,
42
+ `created_at` integer NOT NULL,
43
+ `updated_at` integer NOT NULL,
44
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
45
+ );
46
+ --> statement-breakpoint
47
+ CREATE INDEX `account_user_id_idx` ON `account` (`user_id`);
48
+ --> statement-breakpoint
49
+ CREATE UNIQUE INDEX `account_issuer_account_id_unique` ON `account` (`issuer`,`account_id`);
50
+ --> statement-breakpoint
51
+ CREATE TABLE `verification` (
52
+ `id` text PRIMARY KEY NOT NULL,
53
+ `identifier` text NOT NULL,
54
+ `value` text NOT NULL,
55
+ `expires_at` integer NOT NULL,
56
+ `created_at` integer NOT NULL,
57
+ `updated_at` integer NOT NULL
58
+ );
59
+ --> statement-breakpoint
60
+ CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);
61
+ --> statement-breakpoint
62
+ CREATE TABLE `rate_limit` (
63
+ `id` text PRIMARY KEY NOT NULL,
64
+ `key` text NOT NULL,
65
+ `count` integer NOT NULL,
66
+ `last_request` integer NOT NULL
67
+ );
68
+ --> statement-breakpoint
69
+ CREATE UNIQUE INDEX `rate_limit_key_unique` ON `rate_limit` (`key`);
70
+ --> statement-breakpoint
71
+ CREATE TABLE `app_rate_limit` (
72
+ `key` text PRIMARY KEY NOT NULL,
73
+ `count` integer NOT NULL,
74
+ `reset_at` integer NOT NULL
75
+ );
76
+ --> statement-breakpoint
77
+ CREATE TABLE `messages` (
78
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
79
+ `author_id` text NOT NULL,
80
+ `author` text NOT NULL,
81
+ `body` text NOT NULL,
82
+ `created_at` integer NOT NULL,
83
+ FOREIGN KEY (`author_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
84
+ );
85
+ --> statement-breakpoint
86
+ CREATE INDEX `messages_author_id_idx` ON `messages` (`author_id`);
87
+ --> statement-breakpoint
88
+ CREATE TABLE `uploads` (
89
+ `id` text PRIMARY KEY NOT NULL,
90
+ `owner_id` text NOT NULL,
91
+ `filename` text NOT NULL,
92
+ `media_type` text NOT NULL,
93
+ `length` integer NOT NULL,
94
+ `offset` integer DEFAULT 0 NOT NULL,
95
+ `state` text DEFAULT 'active' NOT NULL,
96
+ `created_at` integer NOT NULL,
97
+ `updated_at` integer NOT NULL,
98
+ FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
99
+ );
100
+ --> statement-breakpoint
101
+ CREATE INDEX `uploads_owner_id_state_idx` ON `uploads` (`owner_id`,`state`);
102
+ --> statement-breakpoint
103
+ CREATE TABLE `outbox` (
104
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
105
+ `channel` text NOT NULL,
106
+ `payload` text NOT NULL,
107
+ `attempts` integer DEFAULT 0 NOT NULL,
108
+ `available_at` integer NOT NULL,
109
+ `created_at` integer NOT NULL,
110
+ `failed_at` integer,
111
+ `last_error` text
112
+ );
113
+ --> statement-breakpoint
114
+ CREATE INDEX `outbox_available_at_idx` ON `outbox` (`available_at`,`id`);
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1787072400000,
9
+ "tag": "0000_production_reference",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,45 @@
1
+ import { expect, test } from '@playwright/test'
2
+
3
+ test('signs up, authorizes resources, persists an upload, and receives an outbox publication', async ({ browser, request }, testInfo) => {
4
+ const run = Date.now()
5
+ const firstMessage = `first message ${run}`
6
+ const realtimeMessage = `realtime message ${run}`
7
+ const origin = testInfo.project.use.baseURL!
8
+ expect((await request.get('/api/live')).status()).toBe(200)
9
+ expect((await request.get('/api/ready')).status()).toBe(200)
10
+ expect((await request.get('/')).headers()['x-content-type-options']).toBe('nosniff')
11
+ expect((await request.post('/api/uploads', { headers: { Origin: origin, 'Upload-Length': '1' } })).status()).toBe(401)
12
+ expect((await request.head('/api/uploads/missing')).status()).toBe(401)
13
+
14
+ const alice = await browser.newPage()
15
+ await alice.goto('/')
16
+ await signUp(alice, 'Alice', 'alice@example.test')
17
+ await alice.getByLabel('Message').fill(firstMessage)
18
+ await alice.getByRole('button', { name: 'Add message' }).click()
19
+ await expect(alice.getByText(`Alice: ${firstMessage}`)).toBeVisible()
20
+
21
+ await alice.getByLabel('Upload').setInputFiles({ name: 'proof.txt', mimeType: 'text/plain', buffer: Buffer.from('workspace proof') })
22
+ const upload = alice.getByText(/Uploaded to .*\/api\/uploads\//)
23
+ await expect(upload).toBeVisible()
24
+ const uploadUrl = (await upload.textContent())!.replace('Uploaded to ', '')
25
+
26
+ const bob = await browser.newPage()
27
+ await bob.goto('/')
28
+ await signUp(bob, 'Bob', 'bob@example.test')
29
+ expect(await bob.evaluate((url) => fetch(url, { method: 'HEAD' }).then((response) => response.status), uploadUrl)).toBe(404)
30
+ await expect(bob.getByText(`Alice: ${firstMessage}`)).toBeVisible()
31
+ await bob.getByLabel('Message').fill(realtimeMessage)
32
+ await bob.getByRole('button', { name: 'Add message' }).click()
33
+ await expect(alice.getByText(`Bob: ${realtimeMessage}`)).toBeVisible()
34
+ await alice.getByRole('button', { name: 'Sign out' }).click()
35
+ await expect(alice.getByRole('heading', { name: 'Create account' })).toBeVisible()
36
+ await alice.screenshot({ path: testInfo.outputPath('example.png'), fullPage: true })
37
+ })
38
+
39
+ async function signUp(page: import('@playwright/test').Page, name: string, email: string) {
40
+ await page.getByLabel('Name').fill(name)
41
+ await page.getByLabel('Email').fill(email)
42
+ await page.getByLabel('Password').fill('correct horse battery staple')
43
+ await page.getByRole('button', { name: 'Create account', exact: true }).click()
44
+ await expect(page.getByRole('heading', { name: `Hello, ${name}` })).toBeVisible()
45
+ }
@@ -0,0 +1,9 @@
1
+ dist/
2
+ node_modules/
3
+ .data/
4
+ .output/
5
+ test-results/
6
+ *.tgz
7
+ .env
8
+ .env.*
9
+ !.env.example
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": ["./node_modules/ras-stack/config/oxlint/application.json", "./node_modules/ras-stack/config/oxlint/tanstack.json"],
3
+ "ignorePatterns": ["src/routeTree.gen.ts", ".output/**", "node_modules/**"]
4
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@ras-stack/example-full-stack",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "build": "vite build && node ../../dist/cli.js assets sync && esbuild scripts/containerRuntime.ts scripts/database.ts --bundle --platform=node --format=esm --target=node24 --external:better-sqlite3 --outdir=.output/server --out-extension:.js=.mjs --log-level=warning && node ../../dist/cli.js assets check",
7
+ "dev": "vite dev",
8
+ "format": "oxfmt --write .",
9
+ "format:check": "oxfmt --check .",
10
+ "lint": "oxlint --config oxlint.json --type-aware --deny-warnings .",
11
+ "realtime": "ras realtime --config centrifugo.json --name ras-stack-example-realtime --port 8100 --origin http://localhost:3100 --secret example-development-secret --connect-proxy-endpoint http://host.docker.internal:3100/api/centrifugo/connect",
12
+ "e2e": "playwright test",
13
+ "test": "vitest run --config vitest.config.ts",
14
+ "typecheck": "tsc --noEmit",
15
+ "db:backup": "node .output/server/database.mjs backup",
16
+ "db:restore": "node .output/server/database.mjs restore",
17
+ "check": "pnpm format:check && pnpm lint && pnpm build && pnpm typecheck && pnpm test"
18
+ },
19
+ "dependencies": {
20
+ "@better-auth/drizzle-adapter": "1.7.1",
21
+ "@opentelemetry/api-logs": "^0.221.0",
22
+ "@opentelemetry/exporter-logs-otlp-http": "^0.221.0",
23
+ "@opentelemetry/resources": "^2.10.0",
24
+ "@opentelemetry/sdk-logs": "^0.221.0",
25
+ "@posthog/react": "^1.10.3",
26
+ "@tanstack/react-query": "^5.101.4",
27
+ "@tanstack/react-router": "^1.170.18",
28
+ "@tanstack/react-router-ssr-query": "^1.167.1",
29
+ "@tanstack/react-start": "^1.168.32",
30
+ "better-auth": "1.7.1",
31
+ "better-sqlite3": "12.11.1",
32
+ "centrifuge": "5.7.0",
33
+ "drizzle-orm": "0.45.2",
34
+ "nodemailer": "^9.0.3",
35
+ "posthog-js": "^1.414.0",
36
+ "posthog-node": "^5.48.1",
37
+ "ras-stack": "workspace:*",
38
+ "react": "19.2.8",
39
+ "react-dom": "19.2.8",
40
+ "tus-js-client": "^4.3.1",
41
+ "zod": "^4.4.3"
42
+ },
43
+ "devDependencies": {
44
+ "@playwright/test": "1.62.1",
45
+ "@types/better-sqlite3": "9.6.0",
46
+ "@types/node": "^26.2.0",
47
+ "@types/react": "19.2.18",
48
+ "@types/react-dom": "^19.2.3",
49
+ "@vitejs/plugin-react": "^6.0.5",
50
+ "esbuild": "0.28.2",
51
+ "nitro": "3.0.260610-beta",
52
+ "oxfmt": "^0.62.0",
53
+ "oxlint": "^1.74.0",
54
+ "typescript": "^7.0.2",
55
+ "vite": "8.2.1",
56
+ "vitest": "^4.1.10"
57
+ }
58
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig, devices } from '@playwright/test'
2
+
3
+ export default defineConfig({
4
+ testDir: './e2e',
5
+ timeout: 30_000,
6
+ use: { ...devices['Desktop Chrome'], baseURL: process.env.EXAMPLE_BASE_URL ?? 'http://127.0.0.1:3110', trace: 'retain-on-failure' },
7
+ })
@@ -0,0 +1,12 @@
1
+ packages:
2
+ - .
3
+ allowBuilds:
4
+ better-sqlite3: true
5
+ core-js: false
6
+ esbuild: true
7
+ protobufjs: true
8
+ minimumReleaseAge: 10080
9
+ minimumReleaseAgeExclude:
10
+ - ras-stack
11
+ - better-auth
12
+ - '@better-auth/*'
@@ -0,0 +1,4 @@
1
+ {
2
+ "outputDirectory": ".output/server",
3
+ "assets": [{ "source": "drizzle", "destination": "drizzle" }]
4
+ }
@@ -0,0 +1,29 @@
1
+ import { runRealtimeStack } from 'ras-stack/runtime'
2
+
3
+ const required = (name: string) => {
4
+ const value = process.env[name]?.trim()
5
+ if (!value) throw new Error(`${name} is required`)
6
+ return value
7
+ }
8
+
9
+ const apiKey = required('CENTRIFUGO_API_KEY')
10
+ const proxySecret = required('CENTRIFUGO_PROXY_SECRET')
11
+ const realtime: NodeJS.ProcessEnv = { ...process.env, CENTRIFUGO_VAR_PROXY_SECRET: proxySecret }
12
+ delete realtime.CENTRIFUGO_API_KEY
13
+ delete realtime.CENTRIFUGO_PROXY_SECRET
14
+ delete realtime.CENTRIFUGO_API_URL
15
+ delete realtime.CENTRIFUGO_CONFIG
16
+
17
+ process.exitCode = await runRealtimeStack({
18
+ app: { command: process.execPath, args: ['.output/server/index.mjs'], env: { ...process.env, PORT: '3101' } },
19
+ centrifugo: {
20
+ configPath: process.env.CENTRIFUGO_CONFIG ?? '/app/centrifugo.json',
21
+ env: realtime,
22
+ environment: { apiKey, allowedOrigins: process.env.APP_URL ?? 'http://localhost:3100' },
23
+ },
24
+ caddy: {
25
+ configPath: '/tmp/ras-stack-example-Caddyfile',
26
+ env: process.env,
27
+ proxy: { publicPort: 3100, appPort: 3101, realtimePort: 8100 },
28
+ },
29
+ })
@@ -0,0 +1,121 @@
1
+ import Database from 'better-sqlite3'
2
+ import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
6
+ import { runDatabaseCommand } from './database'
7
+
8
+ let directory: string
9
+ let source: string
10
+
11
+ beforeEach(async () => {
12
+ directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-backup-'))
13
+ source = path.join(directory, 'example.sqlite')
14
+ writeValue(source, 'before')
15
+ })
16
+
17
+ afterEach(async () => {
18
+ await rm(directory, { recursive: true, force: true })
19
+ })
20
+
21
+ it('backs up and restores a validated database', async () => {
22
+ const backup = path.join(directory, 'backup.sqlite')
23
+ await runDatabaseCommand('backup', backup, { DATA_DIR: directory })
24
+ writeValue(source, 'after')
25
+ await runDatabaseCommand('restore', backup, { DATA_DIR: directory })
26
+ expect(readValue(source)).toBe('before')
27
+ })
28
+
29
+ it('rejects a corrupt restore candidate without changing the source', async () => {
30
+ const corrupt = path.join(directory, 'corrupt.sqlite')
31
+ await writeFile(corrupt, 'not sqlite')
32
+ await expect(runDatabaseCommand('restore', corrupt, { DATA_DIR: directory })).rejects.toThrow('file is not a database')
33
+ expect(readValue(source)).toBe('before')
34
+ })
35
+
36
+ it('restores the original file and removes temporary state when replacement fails', async () => {
37
+ const backup = path.join(directory, 'backup.sqlite')
38
+ await runDatabaseCommand('backup', backup, { DATA_DIR: directory })
39
+ writeValue(source, 'current')
40
+ const operations = await import('node:fs/promises')
41
+ let renames = 0
42
+ const rename = vi.fn(async (from: string, to: string) => {
43
+ renames += 1
44
+ if (renames === 2) throw new Error('replacement failed')
45
+ await operations.rename(from, to)
46
+ }) as typeof operations.rename
47
+ await expect(runDatabaseCommand('restore', backup, { DATA_DIR: directory }, { ...operations, rename })).rejects.toThrow(
48
+ 'replacement failed',
49
+ )
50
+ expect(readValue(source)).toBe('current')
51
+ expect((await readdir(directory)).some((file) => file.includes('.restore-'))).toBe(false)
52
+ })
53
+
54
+ it('restores the original file even when temporary cleanup also fails', async () => {
55
+ const backup = path.join(directory, 'backup.sqlite')
56
+ await runDatabaseCommand('backup', backup, { DATA_DIR: directory })
57
+ writeValue(source, 'current')
58
+ const operations = await import('node:fs/promises')
59
+ let renames = 0
60
+ const rename = vi.fn(async (from: string, to: string) => {
61
+ renames += 1
62
+ if (renames === 2) throw new Error('replacement failed')
63
+ await operations.rename(from, to)
64
+ }) as typeof operations.rename
65
+ const failure = await runDatabaseCommand(
66
+ 'restore',
67
+ backup,
68
+ { DATA_DIR: directory },
69
+ {
70
+ ...operations,
71
+ rename,
72
+ rm: vi.fn().mockRejectedValue(new Error('cleanup failed')),
73
+ },
74
+ ).catch((error: unknown) => error)
75
+ expect(failure).toBeInstanceOf(AggregateError)
76
+ expect((failure as AggregateError).errors.map(String)).toEqual(['Error: replacement failed', 'Error: cleanup failed'])
77
+ expect(readValue(source)).toBe('current')
78
+ })
79
+
80
+ it('reports rollback and cleanup failures after a replacement failure', async () => {
81
+ const backup = path.join(directory, 'backup.sqlite')
82
+ await runDatabaseCommand('backup', backup, { DATA_DIR: directory })
83
+ const operations = await import('node:fs/promises')
84
+ let renames = 0
85
+ const rename = vi.fn(async (from: string, to: string) => {
86
+ renames += 1
87
+ if (renames === 2) throw new Error('replacement failed')
88
+ if (renames === 3) throw new Error('rollback failed')
89
+ await operations.rename(from, to)
90
+ }) as typeof operations.rename
91
+ const failure = await runDatabaseCommand(
92
+ 'restore',
93
+ backup,
94
+ { DATA_DIR: directory },
95
+ {
96
+ ...operations,
97
+ rename,
98
+ rm: vi.fn().mockRejectedValue(new Error('cleanup failed')),
99
+ },
100
+ ).catch((error: unknown) => error)
101
+ expect((failure as AggregateError).errors.map(String)).toEqual([
102
+ 'Error: replacement failed',
103
+ 'Error: rollback failed',
104
+ 'Error: cleanup failed',
105
+ ])
106
+ expect(rename).toHaveBeenCalledTimes(3)
107
+ })
108
+
109
+ function writeValue(file: string, value: string) {
110
+ const database = new Database(file)
111
+ database.exec('DROP TABLE IF EXISTS proof; CREATE TABLE proof (value TEXT NOT NULL)')
112
+ database.prepare('INSERT INTO proof (value) VALUES (?)').run(value)
113
+ database.close()
114
+ }
115
+
116
+ function readValue(file: string) {
117
+ const database = new Database(file, { readonly: true })
118
+ const value = database.prepare('SELECT value FROM proof').pluck().get() as string
119
+ database.close()
120
+ return value
121
+ }
@@ -0,0 +1,105 @@
1
+ import Database from 'better-sqlite3'
2
+ import { existsSync } from 'node:fs'
3
+ import { copyFile, mkdir, rename, rm } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ type FileOperations = { copyFile: typeof copyFile; mkdir: typeof mkdir; rename: typeof rename; rm: typeof rm }
8
+ const defaultFileOperations: FileOperations = { copyFile, mkdir, rename, rm }
9
+
10
+ export async function runDatabaseCommand(
11
+ command: 'backup' | 'restore',
12
+ suppliedPath: string | undefined,
13
+ environment: NodeJS.ProcessEnv = process.env,
14
+ fileOperations: FileOperations = defaultFileOperations,
15
+ ) {
16
+ const dataDirectory = path.resolve(environment.DATA_DIR ?? '.data/example-full-stack')
17
+ const source = path.join(dataDirectory, 'example.sqlite')
18
+ if (command === 'backup') {
19
+ const backupDirectory = path.join(dataDirectory, 'backups')
20
+ await fileOperations.mkdir(backupDirectory, { recursive: true })
21
+ const destination = suppliedPath
22
+ ? path.resolve(suppliedPath)
23
+ : path.join(backupDirectory, `example-${new Date().toISOString().replaceAll(':', '-')}.sqlite`)
24
+ const database = new Database(source, { readonly: true, fileMustExist: true })
25
+ try {
26
+ integrity(database, 'source')
27
+ await database.backup(destination)
28
+ const copy = new Database(destination, { readonly: true, fileMustExist: true })
29
+ try {
30
+ integrity(copy, 'backup')
31
+ } finally {
32
+ copy.close()
33
+ }
34
+ return destination
35
+ } finally {
36
+ database.close()
37
+ }
38
+ }
39
+
40
+ if (!suppliedPath) throw new Error('restore requires a backup file')
41
+ const candidate = path.resolve(suppliedPath)
42
+ const backup = new Database(candidate, { readonly: true, fileMustExist: true })
43
+ try {
44
+ integrity(backup, 'backup')
45
+ } finally {
46
+ backup.close()
47
+ }
48
+ await fileOperations.mkdir(dataDirectory, { recursive: true })
49
+ for (const sidecar of [`${source}-wal`, `${source}-shm`]) {
50
+ if (existsSync(sidecar)) throw new Error(`Refusing to restore while SQLite sidecar exists: ${sidecar}`)
51
+ }
52
+ const temporary = `${source}.restore-${process.pid}`
53
+ await fileOperations.copyFile(candidate, temporary)
54
+ const restored = new Database(temporary, { readonly: true, fileMustExist: true })
55
+ try {
56
+ integrity(restored, 'restored')
57
+ } finally {
58
+ restored.close()
59
+ }
60
+ const previous = `${source}.before-restore-${Date.now()}`
61
+ const hasPrevious = existsSync(source)
62
+ if (hasPrevious) await fileOperations.rename(source, previous)
63
+ try {
64
+ await fileOperations.rename(temporary, source)
65
+ } catch (error) {
66
+ const failures = [error]
67
+ if (hasPrevious) {
68
+ try {
69
+ await fileOperations.rename(previous, source)
70
+ } catch (rollbackError) {
71
+ failures.push(rollbackError)
72
+ }
73
+ }
74
+ try {
75
+ await fileOperations.rm(temporary, { force: true })
76
+ } catch (cleanupError) {
77
+ failures.push(cleanupError)
78
+ }
79
+ if (failures.length === 1) throw error
80
+ const failure = new AggregateError(failures, 'Restore replacement failed and recovery was incomplete')
81
+ failure.cause = error
82
+ throw failure
83
+ }
84
+ return source
85
+ }
86
+
87
+ function integrity(database: Database.Database, label: string) {
88
+ const result = database.pragma('quick_check', { simple: true })
89
+ if (result !== 'ok') throw new Error(`${label} database failed quick_check: ${String(result)}`)
90
+ }
91
+
92
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
93
+ const [command, suppliedPath] = process.argv.slice(2)
94
+ if (command !== 'backup' && command !== 'restore') {
95
+ console.error('usage: database.mjs <backup [destination]|restore backup-file>')
96
+ process.exitCode = 2
97
+ } else {
98
+ try {
99
+ console.log(await runDatabaseCommand(command, suppliedPath))
100
+ } catch (error) {
101
+ console.error(error)
102
+ process.exitCode = 1
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,3 @@
1
+ import { createAuthClient } from 'better-auth/react'
2
+
3
+ export const authClient = createAuthClient()
@@ -0,0 +1,4 @@
1
+ import { queryOptions } from '@tanstack/react-query'
2
+ import { snapshot } from '../server/fns'
3
+
4
+ export const snapshotQuery = () => queryOptions({ queryKey: ['example', 'snapshot'], queryFn: () => snapshot() })
@@ -0,0 +1 @@
1
+ export { createStackQueryClient as createQueryClient, queryErrorMessage } from 'ras-stack/tanstack/query'
@@ -0,0 +1,20 @@
1
+ import { useQueryClient } from '@tanstack/react-query'
2
+ import { createSameOriginRealtimeClient } from 'ras-stack/realtime/client'
3
+ import { useConnectedRealtimeClient, useRealtimeSubscription } from 'ras-stack/realtime/react'
4
+ import { useCallback } from 'react'
5
+ import { snapshotQuery } from './queries'
6
+
7
+ export function useRealtime(enabled: boolean) {
8
+ const queryClient = useQueryClient()
9
+ const create = useCallback(() => createSameOriginRealtimeClient({}), [])
10
+ const client = useConnectedRealtimeClient(create, enabled)
11
+ const configure = useCallback(
12
+ (subscription: NonNullable<ReturnType<typeof useRealtimeSubscription>>) => {
13
+ const refresh = () => void queryClient.invalidateQueries({ queryKey: snapshotQuery().queryKey })
14
+ subscription.on('publication', refresh)
15
+ return () => subscription.off('publication', refresh)
16
+ },
17
+ [queryClient],
18
+ )
19
+ useRealtimeSubscription({ client, channel: 'messages:all', enabled, configure })
20
+ }
@@ -0,0 +1,17 @@
1
+ import { definePostHogCoverage } from 'ras-stack/posthog'
2
+
3
+ export const postHogCoverage = definePostHogCoverage({
4
+ browser: {
5
+ analytics: true,
6
+ errorTracking: true,
7
+ identity: true,
8
+ sessionReplay: { disabled: 'The example does not handle user data worth replaying' },
9
+ featureFlags: { disabled: 'The example has no rollout-controlled behavior' },
10
+ },
11
+ server: {
12
+ analytics: true,
13
+ errorTracking: true,
14
+ logs: true,
15
+ },
16
+ sourceMaps: { disabled: 'The example is not deployed as a user-facing application' },
17
+ })