ras-stack 2.0.1 → 3.0.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 +15 -42
- package/dist/changesets/cli.d.ts +1 -0
- package/dist/changesets/cli.js +14 -0
- package/dist/changesets/cli.js.map +1 -0
- package/dist/{policy/changesets.js → changesets/index.js} +1 -1
- package/dist/changesets/index.js.map +1 -0
- package/dist/cli.d.ts +2 -6
- package/dist/cli.js +3 -7
- package/dist/cli.js.map +1 -1
- package/dist/posthog/index.d.ts +0 -2
- package/dist/posthog/index.js +0 -1
- package/dist/posthog/index.js.map +1 -1
- package/dist/posthog/proxy.d.ts +11 -1
- package/dist/posthog/proxy.js +15 -3
- package/dist/posthog/proxy.js.map +1 -1
- package/package.json +12 -39
- package/config/oxlint/application.json +0 -16
- package/config/oxlint/domain.json +0 -29
- package/config/oxlint/layers.json +0 -116
- package/config/oxlint/tanstack.json +0 -4
- package/config/oxlint.json +0 -19
- package/config/typescript/base.json +0 -10
- package/config/typescript/browser.json +0 -8
- package/config/typescript/bundler.json +0 -9
- package/config/typescript/library.json +0 -11
- package/config/typescript/node-bundler.json +0 -9
- package/config/typescript/tanstack.json +0 -6
- package/dist/create/index.d.ts +0 -1
- package/dist/create/index.js +0 -83
- package/dist/create/index.js.map +0 -1
- package/dist/create/template/.env.example +0 -16
- package/dist/create/template/.oxfmtrc.json +0 -7
- package/dist/create/template/Dockerfile +0 -37
- package/dist/create/template/Dockerfile.standalone +0 -29
- package/dist/create/template/centrifugo.json +0 -14
- package/dist/create/template/dockerignore.template +0 -8
- package/dist/create/template/drizzle/0000_production_reference.sql +0 -114
- package/dist/create/template/drizzle/meta/_journal.json +0 -13
- package/dist/create/template/e2e/full-stack.spec.ts +0 -54
- package/dist/create/template/gitignore.template +0 -9
- package/dist/create/template/oxlint.json +0 -4
- package/dist/create/template/package.json +0 -60
- package/dist/create/template/playwright.config.ts +0 -7
- package/dist/create/template/pnpm-workspace.template.yaml +0 -13
- package/dist/create/template/ras-stack.assets.json +0 -4
- package/dist/create/template/scripts/containerRuntime.ts +0 -29
- package/dist/create/template/scripts/database.test.ts +0 -121
- package/dist/create/template/scripts/database.ts +0 -105
- package/dist/create/template/src/client/auth.ts +0 -3
- package/dist/create/template/src/client/queries.ts +0 -4
- package/dist/create/template/src/client/queryClient.ts +0 -1
- package/dist/create/template/src/client/useRealtime.ts +0 -20
- package/dist/create/template/src/posthog.ts +0 -21
- package/dist/create/template/src/routeTree.gen.ts +0 -230
- package/dist/create/template/src/router.tsx +0 -17
- package/dist/create/template/src/routes/__root.tsx +0 -40
- package/dist/create/template/src/routes/api/auth.$.ts +0 -8
- package/dist/create/template/src/routes/api/centrifugo.connect.ts +0 -29
- package/dist/create/template/src/routes/api/health.ts +0 -6
- package/dist/create/template/src/routes/api/live.ts +0 -5
- package/dist/create/template/src/routes/api/ready.ts +0 -30
- package/dist/create/template/src/routes/api/uploads.$id.ts +0 -40
- package/dist/create/template/src/routes/api/uploads.ts +0 -25
- package/dist/create/template/src/routes/index.tsx +0 -189
- package/dist/create/template/src/server/app.test.ts +0 -24
- package/dist/create/template/src/server/app.ts +0 -172
- package/dist/create/template/src/server/auth-flow.test.ts +0 -150
- package/dist/create/template/src/server/auth.ts +0 -56
- package/dist/create/template/src/server/environment.test.ts +0 -43
- package/dist/create/template/src/server/environment.ts +0 -67
- package/dist/create/template/src/server/fns.ts +0 -38
- package/dist/create/template/src/server/messages.test.ts +0 -38
- package/dist/create/template/src/server/messages.ts +0 -28
- package/dist/create/template/src/server/migration.test.ts +0 -34
- package/dist/create/template/src/server/outbox.test.ts +0 -130
- package/dist/create/template/src/server/outbox.ts +0 -110
- package/dist/create/template/src/server/posthog.test.ts +0 -34
- package/dist/create/template/src/server/rate-limit.test.ts +0 -32
- package/dist/create/template/src/server/rate-limit.ts +0 -29
- package/dist/create/template/src/server/rpc.ts +0 -16
- package/dist/create/template/src/server/schema.ts +0 -134
- package/dist/create/template/src/server/session.test.ts +0 -56
- package/dist/create/template/src/server/session.ts +0 -13
- package/dist/create/template/src/server/uploads.test.ts +0 -142
- package/dist/create/template/src/server/uploads.ts +0 -199
- package/dist/create/template/src/start.ts +0 -12
- package/dist/create/template/src/styles.css +0 -42
- package/dist/create/template/tsconfig.json +0 -8
- package/dist/create/template/vite.config.ts +0 -31
- package/dist/create/template/vitest.config.ts +0 -3
- package/dist/policy/changesets.js.map +0 -1
- package/dist/policy/cli.d.ts +0 -1
- package/dist/policy/cli.js +0 -23
- package/dist/policy/cli.js.map +0 -1
- package/dist/policy/index.d.ts +0 -14
- package/dist/policy/index.js +0 -132
- package/dist/policy/index.js.map +0 -1
- package/dist/policy/init-cli.d.ts +0 -12
- package/dist/policy/init-cli.js +0 -75
- package/dist/policy/init-cli.js.map +0 -1
- package/dist/policy/init.d.ts +0 -24
- package/dist/policy/init.js +0 -116
- package/dist/policy/init.js.map +0 -1
- package/dist/posthog/coverage.d.ts +0 -23
- package/dist/posthog/coverage.js +0 -30
- package/dist/posthog/coverage.js.map +0 -1
- package/dist/tanstack/query.d.ts +0 -3
- package/dist/tanstack/query.js +0 -8
- package/dist/tanstack/query.js.map +0 -1
- /package/dist/{policy/changesets.d.ts → changesets/index.d.ts} +0 -0
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { loadEnvironment } from './environment'
|
|
3
|
-
|
|
4
|
-
describe('startup environment', () => {
|
|
5
|
-
it('requires production state and proxy secrets', () => {
|
|
6
|
-
expect(() => loadEnvironment({ NODE_ENV: 'production', APP_URL: 'https://example.test', DATA_DIR: 'relative' })).toThrow(
|
|
7
|
-
'DATA_DIR must be absolute in production',
|
|
8
|
-
)
|
|
9
|
-
expect(() =>
|
|
10
|
-
loadEnvironment({
|
|
11
|
-
NODE_ENV: 'production',
|
|
12
|
-
APP_URL: 'https://example.test',
|
|
13
|
-
DATA_DIR: '/data',
|
|
14
|
-
CENTRIFUGO_API_URL: 'https://realtime.example.test/api',
|
|
15
|
-
CENTRIFUGO_API_KEY: 'key',
|
|
16
|
-
}),
|
|
17
|
-
).toThrow('CENTRIFUGO_PROXY_SECRET is required when realtime is enabled in production')
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('rejects ambiguous origins and partial realtime credentials', () => {
|
|
21
|
-
expect(() => loadEnvironment({ APP_URL: 'https://example.test/path' })).toThrow('APP_URL must be an HTTP origin')
|
|
22
|
-
expect(() => loadEnvironment({ CENTRIFUGO_API_URL: 'http://localhost/api' })).toThrow(
|
|
23
|
-
'CENTRIFUGO_API_URL and CENTRIFUGO_API_KEY must be configured together',
|
|
24
|
-
)
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
it.each([
|
|
28
|
-
['UPLOAD_GLOBAL_QUOTA_BYTES', '0'],
|
|
29
|
-
['UPLOAD_GLOBAL_MAX_FILES', '0'],
|
|
30
|
-
])('rejects an invalid %s', (name, value) => {
|
|
31
|
-
expect(() => loadEnvironment({ [name]: value })).toThrow(`${name} must be a positive integer`)
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it('requires the global byte cap to accept one maximum-size upload', () => {
|
|
35
|
-
expect(() => loadEnvironment({ UPLOAD_MAX_BYTES: '10', UPLOAD_GLOBAL_QUOTA_BYTES: '9' })).toThrow(
|
|
36
|
-
'UPLOAD_GLOBAL_QUOTA_BYTES must be at least UPLOAD_MAX_BYTES',
|
|
37
|
-
)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('supports production startup with realtime disabled', () => {
|
|
41
|
-
expect(loadEnvironment({ NODE_ENV: 'production', APP_URL: 'https://example.test', DATA_DIR: '/data' }).realtimeEnabled).toBe(false)
|
|
42
|
-
})
|
|
43
|
-
})
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import path from 'node:path'
|
|
2
|
-
|
|
3
|
-
export type AppEnvironment = ReturnType<typeof loadEnvironment>
|
|
4
|
-
|
|
5
|
-
export function loadEnvironment(environment: NodeJS.ProcessEnv = process.env) {
|
|
6
|
-
const production = environment.NODE_ENV === 'production'
|
|
7
|
-
const appUrl = validUrl(environment.APP_URL ?? (production ? '' : 'http://localhost:3100'), 'APP_URL')
|
|
8
|
-
const dataDirectory = path.resolve(environment.DATA_DIR ?? '.data/example-full-stack')
|
|
9
|
-
if (production && !path.isAbsolute(environment.DATA_DIR ?? '')) throw new Error('DATA_DIR must be absolute in production')
|
|
10
|
-
const trustProxy = booleanValue(environment.TRUST_PROXY, 'TRUST_PROXY', false)
|
|
11
|
-
const requireEmailVerification = booleanValue(environment.EMAIL_REQUIRE_VERIFICATION, 'EMAIL_REQUIRE_VERIFICATION', true)
|
|
12
|
-
const centrifugoApiUrl = environment.CENTRIFUGO_API_URL?.trim() ?? ''
|
|
13
|
-
const centrifugoApiKey = environment.CENTRIFUGO_API_KEY?.trim() ?? ''
|
|
14
|
-
if (Boolean(centrifugoApiUrl) !== Boolean(centrifugoApiKey)) {
|
|
15
|
-
throw new Error('CENTRIFUGO_API_URL and CENTRIFUGO_API_KEY must be configured together')
|
|
16
|
-
}
|
|
17
|
-
const realtimeEnabled = Boolean(centrifugoApiUrl)
|
|
18
|
-
if (production && realtimeEnabled && !environment.CENTRIFUGO_PROXY_SECRET?.trim()) {
|
|
19
|
-
throw new Error('CENTRIFUGO_PROXY_SECRET is required when realtime is enabled in production')
|
|
20
|
-
}
|
|
21
|
-
const uploadMaxBytes = positiveInteger(environment.UPLOAD_MAX_BYTES ?? '1000000', 'UPLOAD_MAX_BYTES')
|
|
22
|
-
const uploadQuotaBytes = positiveInteger(environment.UPLOAD_QUOTA_BYTES ?? '5000000', 'UPLOAD_QUOTA_BYTES')
|
|
23
|
-
const uploadGlobalQuotaBytes = positiveInteger(environment.UPLOAD_GLOBAL_QUOTA_BYTES ?? '100000000', 'UPLOAD_GLOBAL_QUOTA_BYTES')
|
|
24
|
-
const uploadGlobalMaxFiles = positiveInteger(environment.UPLOAD_GLOBAL_MAX_FILES ?? '1024', 'UPLOAD_GLOBAL_MAX_FILES')
|
|
25
|
-
if (uploadQuotaBytes < uploadMaxBytes) throw new Error('UPLOAD_QUOTA_BYTES must be at least UPLOAD_MAX_BYTES')
|
|
26
|
-
if (uploadGlobalQuotaBytes < uploadMaxBytes) throw new Error('UPLOAD_GLOBAL_QUOTA_BYTES must be at least UPLOAD_MAX_BYTES')
|
|
27
|
-
return {
|
|
28
|
-
appUrl,
|
|
29
|
-
centrifugoApiKey,
|
|
30
|
-
centrifugoApiUrl,
|
|
31
|
-
dataDirectory,
|
|
32
|
-
production,
|
|
33
|
-
realtimeEnabled,
|
|
34
|
-
requireEmailVerification,
|
|
35
|
-
trustProxy,
|
|
36
|
-
uploadGlobalMaxFiles,
|
|
37
|
-
uploadGlobalQuotaBytes,
|
|
38
|
-
uploadMaxBytes,
|
|
39
|
-
uploadQuotaBytes,
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function validUrl(value: string, name: string) {
|
|
44
|
-
let url: URL
|
|
45
|
-
try {
|
|
46
|
-
url = new URL(value)
|
|
47
|
-
} catch {
|
|
48
|
-
throw new Error(`${name} must be an absolute HTTP URL`)
|
|
49
|
-
}
|
|
50
|
-
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
|
51
|
-
throw new Error(`${name} must be an HTTP origin without credentials, path, query, or fragment`)
|
|
52
|
-
}
|
|
53
|
-
return url.origin
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function booleanValue(value: string | undefined, name: string, fallback: boolean) {
|
|
57
|
-
if (value === undefined) return fallback
|
|
58
|
-
if (value === 'true') return true
|
|
59
|
-
if (value === 'false') return false
|
|
60
|
-
throw new Error(`${name} must be true or false`)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function positiveInteger(value: string, name: string) {
|
|
64
|
-
const parsed = Number(value)
|
|
65
|
-
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${name} must be a positive integer`)
|
|
66
|
-
return parsed
|
|
67
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { createServerFn } from '@tanstack/react-start'
|
|
2
|
-
import { getRequest } from '@tanstack/react-start/server'
|
|
3
|
-
import { desc } from 'drizzle-orm'
|
|
4
|
-
import { z } from 'zod'
|
|
5
|
-
import { app } from './app'
|
|
6
|
-
import { writeMessage } from './messages'
|
|
7
|
-
import { messages } from './schema'
|
|
8
|
-
import { currentUser } from './session'
|
|
9
|
-
import { mutationRpc, rpc } from './rpc'
|
|
10
|
-
import { limitAuthenticatedRequest } from './rate-limit'
|
|
11
|
-
|
|
12
|
-
export const snapshot = createServerFn({ method: 'GET' }).handler(() =>
|
|
13
|
-
rpc(async () => ({
|
|
14
|
-
user: await currentUser(),
|
|
15
|
-
messages: app().database.select().from(messages).orderBy(desc(messages.id)).limit(50).all(),
|
|
16
|
-
emailConfigured: Boolean(app().email),
|
|
17
|
-
realtimeEnabled: app().environment.realtimeEnabled,
|
|
18
|
-
})),
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
export const addMessage = createServerFn({ method: 'POST' })
|
|
22
|
-
.validator(z.object({ body: z.string().trim().min(1).max(280) }))
|
|
23
|
-
.handler(({ data }) =>
|
|
24
|
-
mutationRpc(async () => {
|
|
25
|
-
const request = getRequest()
|
|
26
|
-
const user = await currentUser(request)
|
|
27
|
-
if (!user) throw new Response('Sign in first', { status: 401 })
|
|
28
|
-
await limitAuthenticatedRequest(request, 'messages', user.id, { window: 60, max: 30 })
|
|
29
|
-
const now = new Date()
|
|
30
|
-
const application = app()
|
|
31
|
-
return writeMessage(application.database, application.environment.realtimeEnabled, {
|
|
32
|
-
authorId: user.id,
|
|
33
|
-
author: user.name,
|
|
34
|
-
body: data.body,
|
|
35
|
-
createdAt: now,
|
|
36
|
-
})
|
|
37
|
-
}),
|
|
38
|
-
)
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, rm } from 'node:fs/promises'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import { clearGlobalSingleton } from 'ras-stack/server'
|
|
5
|
-
import { afterEach, beforeEach, expect, it } from 'vitest'
|
|
6
|
-
import { app, closeApp } from './app'
|
|
7
|
-
import { writeMessage } from './messages'
|
|
8
|
-
import { outboxCapacity } from './outbox'
|
|
9
|
-
import { messages, outbox, user } from './schema'
|
|
10
|
-
|
|
11
|
-
let directory: string
|
|
12
|
-
|
|
13
|
-
beforeEach(async () => {
|
|
14
|
-
directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-messages-'))
|
|
15
|
-
process.env.DATA_DIR = directory
|
|
16
|
-
process.env.APP_URL = 'http://localhost:3100'
|
|
17
|
-
const now = new Date()
|
|
18
|
-
app().database.insert(user).values({ id: 'alice', name: 'Alice', email: 'alice@example.test', createdAt: now, updatedAt: now }).run()
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
afterEach(async () => {
|
|
22
|
-
await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
|
|
23
|
-
await rm(directory, { recursive: true, force: true })
|
|
24
|
-
delete process.env.DATA_DIR
|
|
25
|
-
delete process.env.APP_URL
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('writes without adding to a full outbox when realtime is disabled', () => {
|
|
29
|
-
const now = new Date()
|
|
30
|
-
app().database.transaction((transaction) => {
|
|
31
|
-
for (let index = 0; index < outboxCapacity; index += 1) {
|
|
32
|
-
transaction.insert(outbox).values({ channel: 'messages:all', payload: '{}', availableAt: now, createdAt: now }).run()
|
|
33
|
-
}
|
|
34
|
-
})
|
|
35
|
-
writeMessage(app().database, false, { authorId: 'alice', author: 'Alice', body: 'stored without realtime', createdAt: now })
|
|
36
|
-
expect(app().database.select().from(messages).all()).toHaveLength(1)
|
|
37
|
-
expect(app().database.select().from(outbox).all()).toHaveLength(outboxCapacity)
|
|
38
|
-
})
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import type { app } from './app'
|
|
2
|
-
import { assertOutboxCapacity } from './outbox'
|
|
3
|
-
import { messages, outbox } from './schema'
|
|
4
|
-
|
|
5
|
-
type Database = ReturnType<typeof app>['database']
|
|
6
|
-
|
|
7
|
-
export function writeMessage(
|
|
8
|
-
database: Database,
|
|
9
|
-
realtimeEnabled: boolean,
|
|
10
|
-
values: { authorId: string; author: string; body: string; createdAt: Date },
|
|
11
|
-
) {
|
|
12
|
-
return database.transaction((transaction) => {
|
|
13
|
-
if (realtimeEnabled) assertOutboxCapacity(transaction)
|
|
14
|
-
const [message] = transaction.insert(messages).values(values).returning().all()
|
|
15
|
-
if (realtimeEnabled) {
|
|
16
|
-
transaction
|
|
17
|
-
.insert(outbox)
|
|
18
|
-
.values({
|
|
19
|
-
channel: 'messages:all',
|
|
20
|
-
payload: JSON.stringify({ type: 'message-added', id: message!.id }),
|
|
21
|
-
availableAt: values.createdAt,
|
|
22
|
-
createdAt: values.createdAt,
|
|
23
|
-
})
|
|
24
|
-
.run()
|
|
25
|
-
}
|
|
26
|
-
return message!
|
|
27
|
-
})
|
|
28
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import Database from 'better-sqlite3'
|
|
2
|
-
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
-
import os from 'node:os'
|
|
4
|
-
import path from 'node:path'
|
|
5
|
-
import { clearGlobalSingleton } from 'ras-stack/server'
|
|
6
|
-
import { afterEach, expect, it } from 'vitest'
|
|
7
|
-
import { app, closeApp } from './app'
|
|
8
|
-
import { messages } from './schema'
|
|
9
|
-
|
|
10
|
-
let directory: string | undefined
|
|
11
|
-
|
|
12
|
-
afterEach(async () => {
|
|
13
|
-
await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
|
|
14
|
-
if (directory) await rm(directory, { recursive: true, force: true })
|
|
15
|
-
delete process.env.DATA_DIR
|
|
16
|
-
delete process.env.APP_URL
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('preserves rows from the pre-migration messages table', async () => {
|
|
20
|
-
directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-legacy-'))
|
|
21
|
-
process.env.DATA_DIR = directory
|
|
22
|
-
process.env.APP_URL = 'http://localhost:3100'
|
|
23
|
-
const legacy = new Database(path.join(directory, 'example.sqlite'))
|
|
24
|
-
legacy.exec(`CREATE TABLE messages (
|
|
25
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
-
author TEXT NOT NULL,
|
|
27
|
-
body TEXT NOT NULL,
|
|
28
|
-
created_at INTEGER NOT NULL
|
|
29
|
-
); INSERT INTO messages (author, body, created_at) VALUES ('Ada', 'legacy row', 1234);`)
|
|
30
|
-
legacy.close()
|
|
31
|
-
expect(app().database.select().from(messages).all()).toEqual([
|
|
32
|
-
{ id: 1, authorId: 'legacy-import', author: 'Ada', body: 'legacy row', createdAt: new Date(1234) },
|
|
33
|
-
])
|
|
34
|
-
})
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, rm } from 'node:fs/promises'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import { eq } from 'drizzle-orm'
|
|
5
|
-
import { clearGlobalSingleton } from 'ras-stack/server'
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
-
import { app, closeApp } from './app'
|
|
8
|
-
import { assertOutboxCapacity, OutboxWorker, outboxCapacity, outboxStatus } from './outbox'
|
|
9
|
-
import { outbox } from './schema'
|
|
10
|
-
|
|
11
|
-
let directory: string
|
|
12
|
-
|
|
13
|
-
beforeEach(async () => {
|
|
14
|
-
directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-outbox-'))
|
|
15
|
-
process.env.DATA_DIR = directory
|
|
16
|
-
process.env.APP_URL = 'http://localhost:3100'
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
afterEach(async () => {
|
|
20
|
-
vi.useRealTimers()
|
|
21
|
-
await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
|
|
22
|
-
await rm(directory, { recursive: true, force: true })
|
|
23
|
-
delete process.env.DATA_DIR
|
|
24
|
-
delete process.env.APP_URL
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
describe('transactional outbox worker', () => {
|
|
28
|
-
it('retains a failed publication and deletes it only after delivery', async () => {
|
|
29
|
-
const now = new Date()
|
|
30
|
-
app()
|
|
31
|
-
.database.insert(outbox)
|
|
32
|
-
.values({ channel: 'messages:all', payload: JSON.stringify({ id: 1 }), availableAt: now, createdAt: now })
|
|
33
|
-
.run()
|
|
34
|
-
const publish = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(undefined)
|
|
35
|
-
const worker = new OutboxWorker({ database: app().database, enabled: false, publish, batchSize: 1 })
|
|
36
|
-
await worker.drain()
|
|
37
|
-
const retained = app().database.select().from(outbox).get()!
|
|
38
|
-
expect(retained.attempts).toBe(1)
|
|
39
|
-
app()
|
|
40
|
-
.database.update(outbox)
|
|
41
|
-
.set({ availableAt: new Date(0) })
|
|
42
|
-
.where(eq(outbox.id, retained.id))
|
|
43
|
-
.run()
|
|
44
|
-
await worker.drain()
|
|
45
|
-
expect(app().database.select().from(outbox).all()).toEqual([])
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('does not overtake the oldest item while it waits for retry', async () => {
|
|
49
|
-
const now = new Date()
|
|
50
|
-
app()
|
|
51
|
-
.database.insert(outbox)
|
|
52
|
-
.values([
|
|
53
|
-
{ channel: 'messages:all', payload: JSON.stringify({ id: 1 }), availableAt: now, createdAt: now },
|
|
54
|
-
{ channel: 'messages:all', payload: JSON.stringify({ id: 2 }), availableAt: now, createdAt: now },
|
|
55
|
-
])
|
|
56
|
-
.run()
|
|
57
|
-
const publish = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined)
|
|
58
|
-
const worker = new OutboxWorker({ database: app().database, enabled: false, publish })
|
|
59
|
-
await worker.drain()
|
|
60
|
-
await worker.drain()
|
|
61
|
-
expect(publish).toHaveBeenCalledTimes(1)
|
|
62
|
-
app()
|
|
63
|
-
.database.update(outbox)
|
|
64
|
-
.set({ availableAt: new Date(0) })
|
|
65
|
-
.run()
|
|
66
|
-
await worker.drain()
|
|
67
|
-
expect(publish.mock.calls.map(([, payload]) => payload)).toEqual([{ id: 1 }, { id: 1 }, { id: 2 }])
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('dead-letters terminal failures and exposes degraded status', async () => {
|
|
71
|
-
const now = new Date()
|
|
72
|
-
app()
|
|
73
|
-
.database.insert(outbox)
|
|
74
|
-
.values({ channel: 'messages:all', payload: JSON.stringify({ id: 1 }), availableAt: now, createdAt: now })
|
|
75
|
-
.run()
|
|
76
|
-
const worker = new OutboxWorker({
|
|
77
|
-
database: app().database,
|
|
78
|
-
enabled: false,
|
|
79
|
-
publish: vi.fn().mockRejectedValue(new Error('offline')),
|
|
80
|
-
maxAttempts: 2,
|
|
81
|
-
})
|
|
82
|
-
await worker.drain()
|
|
83
|
-
app()
|
|
84
|
-
.database.update(outbox)
|
|
85
|
-
.set({ availableAt: new Date(0) })
|
|
86
|
-
.run()
|
|
87
|
-
await worker.drain()
|
|
88
|
-
expect(outboxStatus(app().database)).toEqual({ pending: 0, failed: 1 })
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('rejects new work when pending and dead-lettered items reach capacity', () => {
|
|
92
|
-
const now = new Date()
|
|
93
|
-
app().database.transaction((transaction) => {
|
|
94
|
-
for (let index = 0; index < outboxCapacity - 1; index += 1) {
|
|
95
|
-
transaction.insert(outbox).values({ channel: 'messages:all', payload: '{}', availableAt: now, createdAt: now }).run()
|
|
96
|
-
}
|
|
97
|
-
transaction.insert(outbox).values({ channel: 'messages:all', payload: '{}', availableAt: now, createdAt: now, failedAt: now }).run()
|
|
98
|
-
})
|
|
99
|
-
expect(capture(() => assertOutboxCapacity(app().database))).toMatchObject({ status: 503 })
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('reports a scheduled drain failure and keeps scheduling', async () => {
|
|
103
|
-
vi.useFakeTimers()
|
|
104
|
-
const onError = vi.fn()
|
|
105
|
-
const worker = new OutboxWorker({ database: app().database, enabled: true, publish: vi.fn(), intervalMs: 10, onError })
|
|
106
|
-
const drain = vi.spyOn(worker, 'drain').mockRejectedValueOnce(new Error('database unavailable')).mockResolvedValue()
|
|
107
|
-
worker.start()
|
|
108
|
-
await vi.advanceTimersByTimeAsync(10)
|
|
109
|
-
expect({ calls: drain.mock.calls.length, errors: onError.mock.calls }).toEqual({
|
|
110
|
-
calls: 2,
|
|
111
|
-
errors: [[expect.objectContaining({ message: 'database unavailable' })]],
|
|
112
|
-
})
|
|
113
|
-
await worker.close()
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
it('propagates the final drain failure during close', async () => {
|
|
117
|
-
const worker = new OutboxWorker({ database: app().database, enabled: true, publish: vi.fn() })
|
|
118
|
-
vi.spyOn(worker, 'drain').mockRejectedValue(new Error('database unavailable'))
|
|
119
|
-
await expect(worker.close()).rejects.toThrow('database unavailable')
|
|
120
|
-
})
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
function capture(operation: () => unknown) {
|
|
124
|
-
try {
|
|
125
|
-
operation()
|
|
126
|
-
} catch (error) {
|
|
127
|
-
return error
|
|
128
|
-
}
|
|
129
|
-
throw new Error('Expected operation to fail')
|
|
130
|
-
}
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { asc, eq, isNull, sql } from 'drizzle-orm'
|
|
2
|
-
import type { app } from './app'
|
|
3
|
-
import { outbox } from './schema'
|
|
4
|
-
|
|
5
|
-
type Database = ReturnType<typeof app>['database']
|
|
6
|
-
export const outboxCapacity = 1_000
|
|
7
|
-
|
|
8
|
-
export class OutboxWorker {
|
|
9
|
-
private timer?: NodeJS.Timeout
|
|
10
|
-
private draining?: Promise<void>
|
|
11
|
-
private scheduled?: Promise<void>
|
|
12
|
-
|
|
13
|
-
constructor(
|
|
14
|
-
private readonly options: {
|
|
15
|
-
database: Database
|
|
16
|
-
enabled: boolean
|
|
17
|
-
publish: (channel: string, payload: unknown) => Promise<void>
|
|
18
|
-
intervalMs?: number
|
|
19
|
-
batchSize?: number
|
|
20
|
-
maxAttempts?: number
|
|
21
|
-
onError?: (error: unknown) => void
|
|
22
|
-
},
|
|
23
|
-
) {}
|
|
24
|
-
|
|
25
|
-
start() {
|
|
26
|
-
if (!this.options.enabled || this.timer) return
|
|
27
|
-
this.timer = setInterval(() => this.scheduleDrain(), this.options.intervalMs ?? 500)
|
|
28
|
-
this.timer.unref()
|
|
29
|
-
this.scheduleDrain()
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
drain() {
|
|
33
|
-
if (this.draining) return this.draining
|
|
34
|
-
this.draining = this.drainBatch().finally(() => {
|
|
35
|
-
this.draining = undefined
|
|
36
|
-
})
|
|
37
|
-
return this.draining
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async close() {
|
|
41
|
-
if (this.timer) clearInterval(this.timer)
|
|
42
|
-
this.timer = undefined
|
|
43
|
-
await this.draining
|
|
44
|
-
if (this.options.enabled) await this.drain()
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
private scheduleDrain() {
|
|
48
|
-
if (this.scheduled) return
|
|
49
|
-
const scheduled = this.drain()
|
|
50
|
-
.catch((error: unknown) => {
|
|
51
|
-
if (this.options.onError) this.options.onError(error)
|
|
52
|
-
else console.error({ event: 'example_outbox_drain_failed', error })
|
|
53
|
-
})
|
|
54
|
-
.finally(() => {
|
|
55
|
-
if (this.scheduled === scheduled) this.scheduled = undefined
|
|
56
|
-
})
|
|
57
|
-
this.scheduled = scheduled
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
private async drainBatch() {
|
|
61
|
-
const items = this.options.database
|
|
62
|
-
.select()
|
|
63
|
-
.from(outbox)
|
|
64
|
-
.where(isNull(outbox.failedAt))
|
|
65
|
-
.orderBy(asc(outbox.id))
|
|
66
|
-
.limit(this.options.batchSize ?? 20)
|
|
67
|
-
.all()
|
|
68
|
-
for (const item of items) {
|
|
69
|
-
if (item.availableAt.getTime() > Date.now()) break
|
|
70
|
-
try {
|
|
71
|
-
const payload: unknown = JSON.parse(item.payload)
|
|
72
|
-
// oxlint-disable-next-line no-await-in-loop
|
|
73
|
-
await this.options.publish(item.channel, payload)
|
|
74
|
-
this.options.database.delete(outbox).where(eq(outbox.id, item.id)).run()
|
|
75
|
-
} catch (error) {
|
|
76
|
-
const attempts = item.attempts + 1
|
|
77
|
-
const failed = attempts >= (this.options.maxAttempts ?? 8)
|
|
78
|
-
const delay = Math.min(60_000, 500 * 2 ** Math.min(attempts, 7))
|
|
79
|
-
this.options.database
|
|
80
|
-
.update(outbox)
|
|
81
|
-
.set({
|
|
82
|
-
attempts,
|
|
83
|
-
availableAt: new Date(Date.now() + delay),
|
|
84
|
-
failedAt: failed ? new Date() : null,
|
|
85
|
-
lastError: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500),
|
|
86
|
-
})
|
|
87
|
-
.where(eq(outbox.id, item.id))
|
|
88
|
-
.run()
|
|
89
|
-
console.error({ event: 'example_outbox_delivery_failed', outboxId: item.id, attempts, error })
|
|
90
|
-
break
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function outboxStatus(database: Pick<Database, 'select'>): { pending: number; failed: number } {
|
|
97
|
-
const status = database
|
|
98
|
-
.select({
|
|
99
|
-
pending: sql<number>`coalesce(sum(case when ${outbox.failedAt} is null then 1 else 0 end), 0)`,
|
|
100
|
-
failed: sql<number>`coalesce(sum(case when ${outbox.failedAt} is not null then 1 else 0 end), 0)`,
|
|
101
|
-
})
|
|
102
|
-
.from(outbox)
|
|
103
|
-
.get()
|
|
104
|
-
return status ?? { pending: 0, failed: 0 }
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function assertOutboxCapacity(database: Pick<Database, 'select'>) {
|
|
108
|
-
const status = outboxStatus(database)
|
|
109
|
-
if (status.pending + status.failed >= outboxCapacity) throw new Response('Realtime queue is full', { status: 503 })
|
|
110
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { postHogEnvironment, postHogRequestContext } from 'ras-stack/posthog'
|
|
3
|
-
import { createManagedPostHogServerTelemetry, createPostHogServerClient } from 'ras-stack/posthog/server'
|
|
4
|
-
import { postHogCoverage } from '../posthog'
|
|
5
|
-
|
|
6
|
-
describe('PostHog integration boundary', () => {
|
|
7
|
-
it('stays disabled without deployment configuration', async () => {
|
|
8
|
-
expect(await createPostHogServerClient(postHogEnvironment({}))).toBeUndefined()
|
|
9
|
-
expect(postHogCoverage.browser.errorTracking).toBe(true)
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
it('propagates a browser session only with the authenticated identity', () => {
|
|
13
|
-
const request = new Request('https://example.test/action', {
|
|
14
|
-
headers: { 'x-posthog-distinct-id': 'person-123', 'x-posthog-session-id': 'session-456' },
|
|
15
|
-
})
|
|
16
|
-
expect(postHogRequestContext(request, { authenticatedDistinctId: 'person-123' })).toEqual({
|
|
17
|
-
distinctId: 'person-123',
|
|
18
|
-
sessionId: 'session-456',
|
|
19
|
-
properties: { $session_id: 'session-456' },
|
|
20
|
-
})
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('keeps the managed server lifecycle optional', async () => {
|
|
24
|
-
const telemetry = createManagedPostHogServerTelemetry({ environment: postHogEnvironment({}), serviceName: 'example' })
|
|
25
|
-
await telemetry.start()
|
|
26
|
-
await telemetry.log({ body: 'request completed', severityText: 'info' })
|
|
27
|
-
await telemetry.metrics.count('request.completed')
|
|
28
|
-
expect(await telemetry.withSpan('request', () => 'completed')).toBe('completed')
|
|
29
|
-
await telemetry.shutdown()
|
|
30
|
-
expect(postHogCoverage.server.logs).toBe(true)
|
|
31
|
-
expect(postHogCoverage.server.metrics).toBe(true)
|
|
32
|
-
expect(postHogCoverage.server.tracing).toBe(true)
|
|
33
|
-
})
|
|
34
|
-
})
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, rm } from 'node:fs/promises'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import { clearGlobalSingleton } from 'ras-stack/server'
|
|
5
|
-
import { afterEach, beforeEach, expect, it } from 'vitest'
|
|
6
|
-
import { closeApp } from './app'
|
|
7
|
-
import { limitAuthenticatedRequest } from './rate-limit'
|
|
8
|
-
|
|
9
|
-
let directory: string
|
|
10
|
-
|
|
11
|
-
beforeEach(async () => {
|
|
12
|
-
directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-rate-limit-'))
|
|
13
|
-
process.env.DATA_DIR = directory
|
|
14
|
-
process.env.APP_URL = 'http://localhost:3100'
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
afterEach(async () => {
|
|
18
|
-
await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
|
|
19
|
-
await rm(directory, { recursive: true, force: true })
|
|
20
|
-
delete process.env.DATA_DIR
|
|
21
|
-
delete process.env.APP_URL
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('keeps authenticated user budgets separate', async () => {
|
|
25
|
-
await limitAuthenticatedRequest(request(), 'messages', 'alice', { window: 60, max: 1 })
|
|
26
|
-
await expect(limitAuthenticatedRequest(request(), 'messages', 'alice', { window: 60, max: 1 })).rejects.toMatchObject({ status: 429 })
|
|
27
|
-
await expect(limitAuthenticatedRequest(request(), 'messages', 'bob', { window: 60, max: 1 })).resolves.toMatchObject({ remaining: 0 })
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
function request() {
|
|
31
|
-
return new Request('http://localhost:3100/messages')
|
|
32
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { sqliteRateLimitStore } from 'ras-stack/database/sqlite'
|
|
2
|
-
import { createRateLimit } from 'ras-stack/server'
|
|
3
|
-
import type { RateLimitRule } from 'ras-stack/auth'
|
|
4
|
-
import { app } from './app'
|
|
5
|
-
|
|
6
|
-
const limiters = new WeakMap<object, Map<string, ReturnType<typeof createRateLimit>>>()
|
|
7
|
-
const identities = new WeakMap<Request, string>()
|
|
8
|
-
|
|
9
|
-
export function limitAuthenticatedRequest(request: Request, scope: string, userId: string, rule: RateLimitRule) {
|
|
10
|
-
const database = app().database
|
|
11
|
-
let databaseLimiters = limiters.get(database)
|
|
12
|
-
if (!databaseLimiters) {
|
|
13
|
-
databaseLimiters = new Map()
|
|
14
|
-
limiters.set(database, databaseLimiters)
|
|
15
|
-
}
|
|
16
|
-
let limiter = databaseLimiters.get(scope)
|
|
17
|
-
if (!limiter) {
|
|
18
|
-
limiter = createRateLimit({
|
|
19
|
-
store: sqliteRateLimitStore(database.$client, 'app_rate_limit'),
|
|
20
|
-
rule,
|
|
21
|
-
scope,
|
|
22
|
-
identify: (candidate) => identities.get(candidate),
|
|
23
|
-
onUnavailable: 'reject',
|
|
24
|
-
})
|
|
25
|
-
databaseLimiters.set(scope, limiter)
|
|
26
|
-
}
|
|
27
|
-
identities.set(request, userId)
|
|
28
|
-
return limiter(request).finally(() => identities.delete(request))
|
|
29
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { createTanStackRpc, requireTanStackMutationOrigin } from 'ras-stack/tanstack/server'
|
|
2
|
-
import { createPostHogRpcLogger } from 'ras-stack/posthog/server'
|
|
3
|
-
import { app } from './app'
|
|
4
|
-
import { currentUser } from './session'
|
|
5
|
-
|
|
6
|
-
const reportRpcError = createPostHogRpcLogger(app().telemetry, {
|
|
7
|
-
logError: (error, context) => console.error({ event: 'example_server_function_failed', ...context, error }),
|
|
8
|
-
resolveAuthenticatedDistinctId: async (request) => (await currentUser(request))?.id,
|
|
9
|
-
allowAnonymousDistinctId: true,
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
export const { rpc, mutationRpc } = createTanStackRpc({
|
|
13
|
-
requireMutation: (request) =>
|
|
14
|
-
requireTanStackMutationOrigin({ configured: [app().environment.appUrl], trustForwardedHeaders: app().environment.trustProxy }, request),
|
|
15
|
-
logError: reportRpcError,
|
|
16
|
-
})
|