ras-stack 2.0.2 → 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.
Files changed (107) hide show
  1. package/README.md +15 -42
  2. package/dist/changesets/cli.d.ts +1 -0
  3. package/dist/changesets/cli.js +14 -0
  4. package/dist/changesets/cli.js.map +1 -0
  5. package/dist/{policy/changesets.js → changesets/index.js} +1 -1
  6. package/dist/changesets/index.js.map +1 -0
  7. package/dist/cli.d.ts +2 -6
  8. package/dist/cli.js +3 -7
  9. package/dist/cli.js.map +1 -1
  10. package/dist/posthog/index.d.ts +0 -2
  11. package/dist/posthog/index.js +0 -1
  12. package/dist/posthog/index.js.map +1 -1
  13. package/package.json +12 -39
  14. package/config/oxlint/application.json +0 -16
  15. package/config/oxlint/domain.json +0 -29
  16. package/config/oxlint/layers.json +0 -116
  17. package/config/oxlint/tanstack.json +0 -4
  18. package/config/oxlint.json +0 -19
  19. package/config/typescript/base.json +0 -10
  20. package/config/typescript/browser.json +0 -8
  21. package/config/typescript/bundler.json +0 -9
  22. package/config/typescript/library.json +0 -11
  23. package/config/typescript/node-bundler.json +0 -9
  24. package/config/typescript/tanstack.json +0 -6
  25. package/dist/create/index.d.ts +0 -1
  26. package/dist/create/index.js +0 -83
  27. package/dist/create/index.js.map +0 -1
  28. package/dist/create/template/.env.example +0 -16
  29. package/dist/create/template/.oxfmtrc.json +0 -7
  30. package/dist/create/template/Dockerfile +0 -37
  31. package/dist/create/template/Dockerfile.standalone +0 -29
  32. package/dist/create/template/centrifugo.json +0 -14
  33. package/dist/create/template/dockerignore.template +0 -8
  34. package/dist/create/template/drizzle/0000_production_reference.sql +0 -114
  35. package/dist/create/template/drizzle/meta/_journal.json +0 -13
  36. package/dist/create/template/e2e/full-stack.spec.ts +0 -54
  37. package/dist/create/template/gitignore.template +0 -9
  38. package/dist/create/template/oxlint.json +0 -4
  39. package/dist/create/template/package.json +0 -60
  40. package/dist/create/template/playwright.config.ts +0 -7
  41. package/dist/create/template/pnpm-workspace.template.yaml +0 -13
  42. package/dist/create/template/ras-stack.assets.json +0 -4
  43. package/dist/create/template/scripts/containerRuntime.ts +0 -29
  44. package/dist/create/template/scripts/database.test.ts +0 -121
  45. package/dist/create/template/scripts/database.ts +0 -105
  46. package/dist/create/template/src/client/auth.ts +0 -3
  47. package/dist/create/template/src/client/queries.ts +0 -4
  48. package/dist/create/template/src/client/queryClient.ts +0 -1
  49. package/dist/create/template/src/client/useRealtime.ts +0 -20
  50. package/dist/create/template/src/posthog.ts +0 -21
  51. package/dist/create/template/src/routeTree.gen.ts +0 -230
  52. package/dist/create/template/src/router.tsx +0 -17
  53. package/dist/create/template/src/routes/__root.tsx +0 -40
  54. package/dist/create/template/src/routes/api/auth.$.ts +0 -8
  55. package/dist/create/template/src/routes/api/centrifugo.connect.ts +0 -29
  56. package/dist/create/template/src/routes/api/health.ts +0 -6
  57. package/dist/create/template/src/routes/api/live.ts +0 -5
  58. package/dist/create/template/src/routes/api/ready.ts +0 -30
  59. package/dist/create/template/src/routes/api/uploads.$id.ts +0 -40
  60. package/dist/create/template/src/routes/api/uploads.ts +0 -25
  61. package/dist/create/template/src/routes/index.tsx +0 -189
  62. package/dist/create/template/src/server/app.test.ts +0 -24
  63. package/dist/create/template/src/server/app.ts +0 -172
  64. package/dist/create/template/src/server/auth-flow.test.ts +0 -150
  65. package/dist/create/template/src/server/auth.ts +0 -56
  66. package/dist/create/template/src/server/environment.test.ts +0 -43
  67. package/dist/create/template/src/server/environment.ts +0 -67
  68. package/dist/create/template/src/server/fns.ts +0 -38
  69. package/dist/create/template/src/server/messages.test.ts +0 -38
  70. package/dist/create/template/src/server/messages.ts +0 -28
  71. package/dist/create/template/src/server/migration.test.ts +0 -34
  72. package/dist/create/template/src/server/outbox.test.ts +0 -130
  73. package/dist/create/template/src/server/outbox.ts +0 -110
  74. package/dist/create/template/src/server/posthog.test.ts +0 -34
  75. package/dist/create/template/src/server/rate-limit.test.ts +0 -32
  76. package/dist/create/template/src/server/rate-limit.ts +0 -29
  77. package/dist/create/template/src/server/rpc.ts +0 -16
  78. package/dist/create/template/src/server/schema.ts +0 -134
  79. package/dist/create/template/src/server/session.test.ts +0 -56
  80. package/dist/create/template/src/server/session.ts +0 -13
  81. package/dist/create/template/src/server/uploads.test.ts +0 -142
  82. package/dist/create/template/src/server/uploads.ts +0 -199
  83. package/dist/create/template/src/start.ts +0 -12
  84. package/dist/create/template/src/styles.css +0 -42
  85. package/dist/create/template/tsconfig.json +0 -8
  86. package/dist/create/template/vite.config.ts +0 -31
  87. package/dist/create/template/vitest.config.ts +0 -3
  88. package/dist/policy/changesets.js.map +0 -1
  89. package/dist/policy/cli.d.ts +0 -1
  90. package/dist/policy/cli.js +0 -23
  91. package/dist/policy/cli.js.map +0 -1
  92. package/dist/policy/index.d.ts +0 -14
  93. package/dist/policy/index.js +0 -132
  94. package/dist/policy/index.js.map +0 -1
  95. package/dist/policy/init-cli.d.ts +0 -12
  96. package/dist/policy/init-cli.js +0 -75
  97. package/dist/policy/init-cli.js.map +0 -1
  98. package/dist/policy/init.d.ts +0 -24
  99. package/dist/policy/init.js +0 -116
  100. package/dist/policy/init.js.map +0 -1
  101. package/dist/posthog/coverage.d.ts +0 -23
  102. package/dist/posthog/coverage.js +0 -30
  103. package/dist/posthog/coverage.js.map +0 -1
  104. package/dist/tanstack/query.d.ts +0 -3
  105. package/dist/tanstack/query.js +0 -8
  106. package/dist/tanstack/query.js.map +0 -1
  107. /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
- })