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.
- package/README.md +17 -8
- package/dist/auth/settings.d.ts +1 -1
- package/dist/auth/settings.js +1 -1
- package/dist/auth/settings.js.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/create/index.d.ts +1 -0
- package/dist/create/index.js +69 -0
- package/dist/create/index.js.map +1 -0
- package/dist/runtime/dev.js +1 -1
- package/dist/runtime/dev.js.map +1 -1
- package/dist/runtime/index.js +77 -13
- package/dist/runtime/index.js.map +1 -1
- package/examples/full-stack/.env.example +16 -0
- package/examples/full-stack/.oxfmtrc.json +7 -0
- package/examples/full-stack/Dockerfile +37 -0
- package/examples/full-stack/Dockerfile.standalone +28 -0
- package/examples/full-stack/centrifugo.json +14 -0
- package/examples/full-stack/dockerignore.template +8 -0
- package/examples/full-stack/drizzle/0000_production_reference.sql +114 -0
- package/examples/full-stack/drizzle/meta/_journal.json +13 -0
- package/examples/full-stack/e2e/full-stack.spec.ts +45 -0
- package/examples/full-stack/gitignore.template +9 -0
- package/examples/full-stack/oxlint.json +4 -0
- package/examples/full-stack/package.json +58 -0
- package/examples/full-stack/playwright.config.ts +7 -0
- package/examples/full-stack/pnpm-workspace.template.yaml +12 -0
- package/examples/full-stack/ras-stack.assets.json +4 -0
- package/examples/full-stack/scripts/containerRuntime.ts +29 -0
- package/examples/full-stack/scripts/database.test.ts +121 -0
- package/examples/full-stack/scripts/database.ts +105 -0
- package/examples/full-stack/src/client/auth.ts +3 -0
- package/examples/full-stack/src/client/queries.ts +4 -0
- package/examples/full-stack/src/client/queryClient.ts +1 -0
- package/examples/full-stack/src/client/useRealtime.ts +20 -0
- package/examples/full-stack/src/posthog.ts +17 -0
- package/examples/full-stack/src/routeTree.gen.ts +230 -0
- package/examples/full-stack/src/router.tsx +17 -0
- package/examples/full-stack/src/routes/__root.tsx +38 -0
- package/examples/full-stack/src/routes/api/auth.$.ts +8 -0
- package/examples/full-stack/src/routes/api/centrifugo.connect.ts +29 -0
- package/examples/full-stack/src/routes/api/health.ts +6 -0
- package/examples/full-stack/src/routes/api/live.ts +5 -0
- package/examples/full-stack/src/routes/api/ready.ts +30 -0
- package/examples/full-stack/src/routes/api/uploads.$id.ts +40 -0
- package/examples/full-stack/src/routes/api/uploads.ts +25 -0
- package/examples/full-stack/src/routes/index.tsx +187 -0
- package/examples/full-stack/src/server/app.test.ts +24 -0
- package/examples/full-stack/src/server/app.ts +172 -0
- package/examples/full-stack/src/server/auth-flow.test.ts +148 -0
- package/examples/full-stack/src/server/auth.ts +60 -0
- package/examples/full-stack/src/server/environment.test.ts +43 -0
- package/examples/full-stack/src/server/environment.ts +67 -0
- package/examples/full-stack/src/server/fns.ts +38 -0
- package/examples/full-stack/src/server/messages.test.ts +38 -0
- package/examples/full-stack/src/server/messages.ts +28 -0
- package/examples/full-stack/src/server/migration.test.ts +34 -0
- package/examples/full-stack/src/server/outbox.test.ts +130 -0
- package/examples/full-stack/src/server/outbox.ts +110 -0
- package/examples/full-stack/src/server/posthog.test.ts +30 -0
- package/examples/full-stack/src/server/rate-limit.test.ts +32 -0
- package/examples/full-stack/src/server/rate-limit.ts +29 -0
- package/examples/full-stack/src/server/rpc.ts +16 -0
- package/examples/full-stack/src/server/schema.ts +134 -0
- package/examples/full-stack/src/server/session.test.ts +56 -0
- package/examples/full-stack/src/server/session.ts +13 -0
- package/examples/full-stack/src/server/uploads.test.ts +142 -0
- package/examples/full-stack/src/server/uploads.ts +199 -0
- package/examples/full-stack/src/start.ts +12 -0
- package/examples/full-stack/src/styles.css +42 -0
- package/examples/full-stack/tsconfig.json +8 -0
- package/examples/full-stack/vite.config.ts +31 -0
- package/examples/full-stack/vitest.config.ts +3 -0
- 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,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,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,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,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 @@
|
|
|
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
|
+
})
|