bunderstack 0.16.0 → 0.17.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -138
- package/package.json +24 -14
- package/src/access.ts +6 -21
- package/src/api/builder.ts +52 -0
- package/src/api/context.ts +83 -0
- package/src/api/crud-router.ts +386 -0
- package/src/api/openapi.ts +184 -0
- package/src/api/realtime-router.ts +75 -0
- package/src/api/registry.ts +338 -0
- package/src/api/router.ts +34 -0
- package/src/api/storage-router.ts +224 -0
- package/src/api/types.ts +105 -0
- package/src/auth.ts +5 -0
- package/src/blueprint.ts +88 -105
- package/src/config.ts +57 -39
- package/src/crud-operations.ts +478 -0
- package/src/dialect.ts +1 -1
- package/src/email/smtp.ts +1 -1
- package/src/env.ts +19 -13
- package/src/errors.ts +98 -26
- package/src/handler.ts +16 -44
- package/src/index.ts +248 -247
- package/src/jobs/define.ts +8 -10
- package/src/jobs/queue.ts +6 -2
- package/src/jobs/worker.ts +4 -1
- package/src/list-query.ts +50 -133
- package/src/manifest.ts +84 -87
- package/src/rate-limit.ts +1 -1
- package/src/realtime/facade.ts +36 -14
- package/src/realtime/filter.ts +81 -0
- package/src/realtime/heartbeat.ts +80 -0
- package/src/realtime/publisher.ts +46 -0
- package/src/standard-schema.ts +59 -0
- package/src/storage/index.ts +8 -0
- package/src/storage/operations.ts +398 -0
- package/src/crud.ts +0 -400
- package/src/realtime/index.ts +0 -242
- package/src/realtime/redis.ts +0 -219
- package/src/routes.ts +0 -137
- package/src/storage/router.ts +0 -531
- package/src/trpc.ts +0 -57
package/src/jobs/define.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
// src/jobs/define.ts — job definition types and the typed builder.
|
|
2
|
-
// `createJobsBuilder`
|
|
2
|
+
// `createJobsBuilder` exists purely to carry
|
|
3
3
|
// TSchema/TEnvResult typing into inline callbacks and extracted files.
|
|
4
|
-
import type {
|
|
4
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
5
5
|
|
|
6
6
|
import type { DbFor } from '../db'
|
|
7
7
|
import type { EmailFacade } from '../email'
|
|
8
8
|
import type { StorageFacade } from '../index'
|
|
9
9
|
|
|
10
10
|
import { parseCron } from './cron'
|
|
11
|
-
|
|
12
11
|
import { CRON_PREFIX, type CatchUp } from './slots'
|
|
13
12
|
|
|
14
13
|
export const DEFAULT_RETRIES = 3
|
|
@@ -33,7 +32,7 @@ export type TickResult = {
|
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
/**
|
|
36
|
-
* The untyped runtime facade.
|
|
35
|
+
* The untyped runtime facade. Job handlers and API context expose this shape;
|
|
37
36
|
* `app.jobs` narrows `enqueue` to the declared job names/payloads.
|
|
38
37
|
*/
|
|
39
38
|
export type JobsRuntimeFacade = {
|
|
@@ -71,8 +70,8 @@ export type QueueJobDefinition<
|
|
|
71
70
|
TEnvResult = Record<string, unknown>,
|
|
72
71
|
> = {
|
|
73
72
|
kind: 'job'
|
|
74
|
-
/**
|
|
75
|
-
input?:
|
|
73
|
+
/** Standard Schema payload; parsed at enqueue AND before the handler runs. */
|
|
74
|
+
input?: StandardSchemaV1<unknown, TInput>
|
|
76
75
|
/** Attempts after the first failure. Default 3 (so 4 total attempts). */
|
|
77
76
|
retries?: number
|
|
78
77
|
/** Delay before retry N (1-based). Default exponential: 1s, 2s, 4s, … */
|
|
@@ -232,7 +231,7 @@ export function createJobsBuilder<
|
|
|
232
231
|
TEnvResult = Record<string, unknown>,
|
|
233
232
|
>() {
|
|
234
233
|
return {
|
|
235
|
-
/** Identity with inference: pins TInput from the
|
|
234
|
+
/** Identity with inference: pins TInput from the schema output. */
|
|
236
235
|
job<TInput = undefined>(
|
|
237
236
|
def: Omit<QueueJobDefinition<TInput, TSchema, TEnvResult>, 'kind'>,
|
|
238
237
|
): QueueJobDefinition<TInput, TSchema, TEnvResult> {
|
|
@@ -260,9 +259,8 @@ export type BunderstackJobsBuilder<
|
|
|
260
259
|
|
|
261
260
|
// Infers TInput from the JobDefinition's own type argument rather than
|
|
262
261
|
// pattern-matching the (optional, so union-with-undefined) `input` property —
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
// can never satisfy a required-property pattern.
|
|
262
|
+
// A required-property pattern fails structurally because `input` is optional,
|
|
263
|
+
// so infer from the definition's own type argument instead.
|
|
266
264
|
type JobInputOf<TDef> =
|
|
267
265
|
TDef extends QueueJobDefinition<infer TInput, any, any> ? TInput : undefined
|
|
268
266
|
|
package/src/jobs/queue.ts
CHANGED
|
@@ -5,8 +5,8 @@ import type { AnyDb } from '../dialect'
|
|
|
5
5
|
import type { EnqueueOptions, JobsDefs } from './define'
|
|
6
6
|
|
|
7
7
|
import { jobsTableFor } from '../internal-tables'
|
|
8
|
+
import { validateStandardSchema } from '../standard-schema'
|
|
8
9
|
import { generate } from '../typeid'
|
|
9
|
-
|
|
10
10
|
import { CRON_PREFIX } from './slots'
|
|
11
11
|
|
|
12
12
|
export async function enqueueJob(
|
|
@@ -23,7 +23,11 @@ export async function enqueueJob(
|
|
|
23
23
|
const isCron = def.kind === 'cron'
|
|
24
24
|
const type = isCron ? `${CRON_PREFIX}${name}` : name
|
|
25
25
|
// Cron slots carry no payload; queue jobs validate theirs at the call site.
|
|
26
|
-
const parsed = isCron
|
|
26
|
+
const parsed = isCron
|
|
27
|
+
? null
|
|
28
|
+
: def.input
|
|
29
|
+
? validateStandardSchema(def.input, input, `job "${name}" input`)
|
|
30
|
+
: null
|
|
27
31
|
const t = jobsTableFor(db)
|
|
28
32
|
const now = Date.now()
|
|
29
33
|
const runAt =
|
package/src/jobs/worker.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
} from './define'
|
|
13
13
|
|
|
14
14
|
import { jobsTableFor } from '../internal-tables'
|
|
15
|
+
import { validateStandardSchema } from '../standard-schema'
|
|
15
16
|
import { parseCron } from './cron'
|
|
16
17
|
import { backoffMs, DEFAULT_RETRIES, DEFAULT_TIMEOUT_MS } from './define'
|
|
17
18
|
import { enqueueJob } from './queue'
|
|
@@ -73,7 +74,9 @@ export function createJobRunner(deps: {
|
|
|
73
74
|
return { scheduledFor: new Date(Number(row.runAt)) }
|
|
74
75
|
}
|
|
75
76
|
const raw = JSON.parse(row.payloadJson)
|
|
76
|
-
return def.input
|
|
77
|
+
return def.input
|
|
78
|
+
? validateStandardSchema(def.input, raw, 'job payload')
|
|
79
|
+
: undefined
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
/**
|
package/src/list-query.ts
CHANGED
|
@@ -21,18 +21,28 @@ import type { AnyDb } from './dialect'
|
|
|
21
21
|
|
|
22
22
|
import { ErrorCode, ListQueryError } from './errors'
|
|
23
23
|
|
|
24
|
-
/** Caps both `?limit=` and the number of values in
|
|
24
|
+
/** Caps both `?limit=` and the number of values in an `IN` filter. */
|
|
25
25
|
export const MAX_LIST_LIMIT = 200
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
/** Default page size when a request omits `limit`. */
|
|
28
|
+
export const DEFAULT_LIST_LIMIT = 20
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What a list procedure accepts. Shape and column types are enforced by the
|
|
32
|
+
* generated input schema, so by the time these params arrive they are already
|
|
33
|
+
* validated and coerced — this module only applies policy (defaults, the limit
|
|
34
|
+
* cap, cursor rules).
|
|
35
|
+
*/
|
|
36
|
+
export type ListParamsInput = {
|
|
37
|
+
limit?: number
|
|
38
|
+
offset?: number
|
|
39
|
+
cursor?: string
|
|
40
|
+
sort?: string
|
|
41
|
+
order?: SortOrder
|
|
42
|
+
q?: string
|
|
43
|
+
count?: boolean
|
|
44
|
+
filters?: Record<string, unknown>
|
|
45
|
+
}
|
|
36
46
|
|
|
37
47
|
export type ParsedListParams = {
|
|
38
48
|
limit: number
|
|
@@ -81,76 +91,22 @@ function isCursorPayload(value: unknown): value is CursorPayload {
|
|
|
81
91
|
)
|
|
82
92
|
}
|
|
83
93
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (raw === undefined || raw === '') return 20
|
|
92
|
-
const n = Number(raw)
|
|
93
|
-
if (!Number.isInteger(n) || n < 1) {
|
|
94
|
-
throw new ListQueryError(
|
|
95
|
-
`limit must be an integer between 1 and ${MAX_LIST_LIMIT}`,
|
|
96
|
-
)
|
|
97
|
-
}
|
|
98
|
-
return Math.min(n, MAX_LIST_LIMIT)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function parseOffset(raw: string | undefined): number {
|
|
102
|
-
if (raw === undefined || raw === '') return 0
|
|
103
|
-
const n = Number(raw)
|
|
104
|
-
if (!Number.isInteger(n) || n < 0) {
|
|
105
|
-
throw new ListQueryError('offset must be a non-negative integer')
|
|
106
|
-
}
|
|
107
|
-
return n
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function parseOrder(raw: string | undefined): SortOrder {
|
|
111
|
-
if (!raw || raw === 'asc') return 'asc'
|
|
112
|
-
if (raw === 'desc') return 'desc'
|
|
113
|
-
throw new ListQueryError('order must be "asc" or "desc"')
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function parseListParams(
|
|
117
|
-
url: URL,
|
|
94
|
+
/**
|
|
95
|
+
* Applies list policy to already-validated input: defaults, the limit cap, and
|
|
96
|
+
* the rules a schema cannot express (cursor excludes offset, and a cursor must
|
|
97
|
+
* agree with the sort it was minted for).
|
|
98
|
+
*/
|
|
99
|
+
export function resolveListParams(
|
|
100
|
+
input: ListParamsInput,
|
|
118
101
|
access: ResolvedTableAccess,
|
|
119
102
|
): ParsedListParams {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
const cursor = params.get('cursor')?.trim() || undefined
|
|
123
|
-
const hasOffset = params.has('offset') && params.get('offset') !== ''
|
|
124
|
-
const offset = hasOffset
|
|
125
|
-
? parseOffset(params.get('offset') ?? undefined)
|
|
126
|
-
: undefined
|
|
127
|
-
|
|
128
|
-
if (cursor && hasOffset) {
|
|
103
|
+
const cursor = input.cursor?.trim() || undefined
|
|
104
|
+
if (cursor && input.offset !== undefined) {
|
|
129
105
|
throw new ListQueryError('cursor and offset cannot be used together')
|
|
130
106
|
}
|
|
131
107
|
|
|
132
|
-
const sort =
|
|
133
|
-
const order =
|
|
134
|
-
? parseOrder(params.get('order') ?? undefined)
|
|
135
|
-
: params.has('sort')
|
|
136
|
-
? 'asc'
|
|
137
|
-
: access.defaultSort.order
|
|
138
|
-
|
|
139
|
-
if (!access.sortableColumns.includes(sort)) {
|
|
140
|
-
throw new ListQueryError(`sort column "${sort}" is not allowed`)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const filters: Record<string, unknown> = {}
|
|
144
|
-
for (const [key, value] of params.entries()) {
|
|
145
|
-
if (RESERVED_LIST_PARAMS.has(key)) continue
|
|
146
|
-
if (!access.filterableColumns.includes(key)) {
|
|
147
|
-
throw new ListQueryError(`filter column "${key}" is not allowed`)
|
|
148
|
-
}
|
|
149
|
-
filters[key] = value === 'null' ? null : value
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const q = params.get('q')?.trim().slice(0, 100) ?? ''
|
|
153
|
-
const count = parseBoolean(params.get('count') ?? undefined)
|
|
108
|
+
const sort = input.sort ?? access.defaultSort.column
|
|
109
|
+
const order = input.order ?? (input.sort ? 'asc' : access.defaultSort.order)
|
|
154
110
|
|
|
155
111
|
if (cursor) {
|
|
156
112
|
const decoded = decodeCursor(cursor)
|
|
@@ -163,14 +119,14 @@ export function parseListParams(
|
|
|
163
119
|
}
|
|
164
120
|
|
|
165
121
|
return {
|
|
166
|
-
limit,
|
|
167
|
-
offset: cursor ? undefined : (offset ?? 0),
|
|
122
|
+
limit: Math.min(input.limit ?? DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT),
|
|
123
|
+
offset: cursor ? undefined : (input.offset ?? 0),
|
|
168
124
|
sort,
|
|
169
125
|
order,
|
|
170
|
-
q,
|
|
126
|
+
q: input.q?.trim() ?? '',
|
|
171
127
|
cursor,
|
|
172
|
-
count,
|
|
173
|
-
filters,
|
|
128
|
+
count: input.count ?? false,
|
|
129
|
+
filters: input.filters ?? {},
|
|
174
130
|
}
|
|
175
131
|
}
|
|
176
132
|
|
|
@@ -191,41 +147,20 @@ function buildSearchWhere(
|
|
|
191
147
|
return conditions.length ? or(...conditions) : undefined
|
|
192
148
|
}
|
|
193
149
|
|
|
194
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Cursors carry their sort value as JSON, so a date column arrives as a string
|
|
152
|
+
* and has to be rebuilt. Filter values need no such repair — the generated
|
|
153
|
+
* input schema already types them.
|
|
154
|
+
*/
|
|
155
|
+
function coerceCursorValue(
|
|
195
156
|
table: Parameters<typeof getTableColumns>[0],
|
|
196
157
|
columnName: string,
|
|
197
|
-
raw:
|
|
158
|
+
raw: string | number | null,
|
|
198
159
|
): unknown {
|
|
199
160
|
if (raw === null) return null
|
|
200
161
|
const col = getTableColumns(table)[columnName]
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
const dataType = col.dataType
|
|
204
|
-
if (
|
|
205
|
-
dataType === 'number' ||
|
|
206
|
-
dataType === 'integer' ||
|
|
207
|
-
dataType === 'bigint'
|
|
208
|
-
) {
|
|
209
|
-
const n = Number(raw)
|
|
210
|
-
if (Number.isNaN(n)) {
|
|
211
|
-
throw new ListQueryError(`filter "${columnName}" must be a number`)
|
|
212
|
-
}
|
|
213
|
-
return n
|
|
214
|
-
}
|
|
215
|
-
if (dataType === 'boolean') {
|
|
216
|
-
const s = String(raw).toLowerCase()
|
|
217
|
-
if (s === 'true' || s === '1') return true
|
|
218
|
-
if (s === 'false' || s === '0') return false
|
|
219
|
-
throw new ListQueryError(`filter "${columnName}" must be a boolean`)
|
|
220
|
-
}
|
|
221
|
-
if (dataType === 'date') {
|
|
222
|
-
const d = new Date(raw as string | number)
|
|
223
|
-
if (Number.isNaN(d.getTime())) {
|
|
224
|
-
throw new ListQueryError(`filter "${columnName}" must be a valid date`)
|
|
225
|
-
}
|
|
226
|
-
return d
|
|
227
|
-
}
|
|
228
|
-
return String(raw)
|
|
162
|
+
if (col?.dataType === 'date') return new Date(raw)
|
|
163
|
+
return raw
|
|
229
164
|
}
|
|
230
165
|
|
|
231
166
|
function buildFilterWhere(
|
|
@@ -235,32 +170,14 @@ function buildFilterWhere(
|
|
|
235
170
|
const columns = getTableColumns(table)
|
|
236
171
|
const conditions: SQL[] = []
|
|
237
172
|
|
|
238
|
-
for (const [name,
|
|
173
|
+
for (const [name, value] of Object.entries(filters)) {
|
|
239
174
|
const col = columns[name]
|
|
240
|
-
if (!col) continue
|
|
241
|
-
|
|
242
|
-
if (raw === null) {
|
|
243
|
-
conditions.push(sql`${col} IS NULL`)
|
|
244
|
-
continue
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// `?column=a,b,c` — TypeIDs and other filterable values never contain
|
|
248
|
-
// commas, so this is a safe, zero-syntax way to do `column IN (...)`.
|
|
249
|
-
if (typeof raw === 'string' && raw.includes(',')) {
|
|
250
|
-
const parts = raw.split(',').filter((p) => p.length > 0)
|
|
251
|
-
if (parts.length > MAX_LIST_LIMIT) {
|
|
252
|
-
throw new ListQueryError(
|
|
253
|
-
`filter "${name}" accepts at most ${MAX_LIST_LIMIT} comma-separated values`,
|
|
254
|
-
)
|
|
255
|
-
}
|
|
256
|
-
const values = parts.map((p) => coerceFilterValue(table, name, p))
|
|
257
|
-
conditions.push(inArray(col, values))
|
|
258
|
-
continue
|
|
259
|
-
}
|
|
175
|
+
if (!col || value === undefined) continue
|
|
260
176
|
|
|
261
|
-
const value = coerceFilterValue(table, name, raw)
|
|
262
177
|
if (value === null) {
|
|
263
178
|
conditions.push(sql`${col} IS NULL`)
|
|
179
|
+
} else if (Array.isArray(value)) {
|
|
180
|
+
if (value.length) conditions.push(inArray(col, value))
|
|
264
181
|
} else {
|
|
265
182
|
conditions.push(eq(col, value))
|
|
266
183
|
}
|
|
@@ -301,7 +218,7 @@ function buildCursorWhere(
|
|
|
301
218
|
): SQL {
|
|
302
219
|
const columns = getTableColumns(table)
|
|
303
220
|
const sortCol = columns[sortColName]!
|
|
304
|
-
const sortValue =
|
|
221
|
+
const sortValue = coerceCursorValue(table, sortColName, cursor.v)
|
|
305
222
|
|
|
306
223
|
if (order === 'desc') {
|
|
307
224
|
return or(
|
package/src/manifest.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
2
|
+
|
|
1
3
|
import { getTableName, isTable } from 'drizzle-orm'
|
|
2
|
-
import
|
|
4
|
+
import * as v from 'valibot'
|
|
3
5
|
|
|
4
6
|
import type { Dialect } from './dialect'
|
|
5
7
|
import type { EnvConfigInput } from './env'
|
|
@@ -12,6 +14,10 @@ import {
|
|
|
12
14
|
bunderstackJobs,
|
|
13
15
|
} from './internal-tables'
|
|
14
16
|
import { parseCron } from './jobs/cron'
|
|
17
|
+
import {
|
|
18
|
+
StandardSchemaValidationError,
|
|
19
|
+
validateStandardSchema,
|
|
20
|
+
} from './standard-schema'
|
|
15
21
|
|
|
16
22
|
export type ManifestEnvVar = {
|
|
17
23
|
key: string
|
|
@@ -43,95 +49,77 @@ export type BunderstackManifest = {
|
|
|
43
49
|
}
|
|
44
50
|
}
|
|
45
51
|
|
|
46
|
-
const nonEmpty =
|
|
47
|
-
const migrationDirectory =
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
(
|
|
51
|
-
value.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
const nonEmpty = v.pipe(v.string(), v.minLength(1))
|
|
53
|
+
const migrationDirectory = v.pipe(
|
|
54
|
+
nonEmpty,
|
|
55
|
+
v.check(
|
|
56
|
+
(value) =>
|
|
57
|
+
value.startsWith('/') ||
|
|
58
|
+
(!value.includes('\\') &&
|
|
59
|
+
value.split('/').every((part) => part !== '..' && part !== '')),
|
|
60
|
+
'migrationsDirectory must be an absolute path or a relative path without traversal',
|
|
61
|
+
),
|
|
56
62
|
)
|
|
57
|
-
const cronSchedule =
|
|
58
|
-
|
|
63
|
+
const cronSchedule = v.pipe(
|
|
64
|
+
nonEmpty,
|
|
65
|
+
v.check((value) => {
|
|
59
66
|
try {
|
|
60
67
|
parseCron(value)
|
|
61
68
|
return true
|
|
62
69
|
} catch {
|
|
63
70
|
return false
|
|
64
71
|
}
|
|
65
|
-
},
|
|
66
|
-
{ message: 'invalid cron schedule' },
|
|
72
|
+
}, 'invalid cron schedule'),
|
|
67
73
|
)
|
|
68
74
|
|
|
69
|
-
const manifestSchema =
|
|
70
|
-
.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
physicalName: nonEmpty,
|
|
81
|
-
system: z.boolean(),
|
|
82
|
-
})
|
|
83
|
-
.strict(),
|
|
84
|
-
),
|
|
85
|
-
})
|
|
86
|
-
.strict(),
|
|
87
|
-
storage: z
|
|
88
|
-
.object({
|
|
89
|
-
defaultBucket: nonEmpty,
|
|
90
|
-
buckets: z.array(
|
|
91
|
-
z
|
|
92
|
-
.object({
|
|
93
|
-
name: nonEmpty,
|
|
94
|
-
visibility: z.enum(['public', 'private']),
|
|
95
|
-
})
|
|
96
|
-
.strict(),
|
|
97
|
-
),
|
|
98
|
-
})
|
|
99
|
-
.strict(),
|
|
100
|
-
realtime: z.object({ required: z.boolean() }).strict(),
|
|
101
|
-
environment: z.array(
|
|
102
|
-
z
|
|
103
|
-
.object({
|
|
104
|
-
key: nonEmpty,
|
|
105
|
-
required: z.boolean(),
|
|
106
|
-
scope: z.enum(['server', 'client']),
|
|
107
|
-
})
|
|
108
|
-
.strict(),
|
|
75
|
+
const manifestSchema = v.strictObject({
|
|
76
|
+
version: v.literal(3),
|
|
77
|
+
database: v.strictObject({
|
|
78
|
+
dialect: v.picklist(['sqlite', 'pg']),
|
|
79
|
+
migrationsDirectory: migrationDirectory,
|
|
80
|
+
tables: v.array(
|
|
81
|
+
v.strictObject({
|
|
82
|
+
exportName: nonEmpty,
|
|
83
|
+
physicalName: nonEmpty,
|
|
84
|
+
system: v.boolean(),
|
|
85
|
+
}),
|
|
109
86
|
),
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
87
|
+
}),
|
|
88
|
+
storage: v.strictObject({
|
|
89
|
+
defaultBucket: nonEmpty,
|
|
90
|
+
buckets: v.array(
|
|
91
|
+
v.strictObject({
|
|
92
|
+
name: nonEmpty,
|
|
93
|
+
visibility: v.picklist(['public', 'private']),
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
}),
|
|
97
|
+
realtime: v.strictObject({ required: v.boolean() }),
|
|
98
|
+
environment: v.array(
|
|
99
|
+
v.strictObject({
|
|
100
|
+
key: nonEmpty,
|
|
101
|
+
required: v.boolean(),
|
|
102
|
+
scope: v.picklist(['server', 'client']),
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
background: v.strictObject({
|
|
106
|
+
jobs: v.array(v.strictObject({ name: nonEmpty })),
|
|
107
|
+
cron: v.array(
|
|
108
|
+
v.strictObject({
|
|
109
|
+
name: nonEmpty,
|
|
110
|
+
schedule: cronSchedule,
|
|
111
|
+
timezone: v.literal('UTC'),
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
maintenance: v.array(
|
|
115
|
+
v.strictObject({
|
|
116
|
+
name: v.literal('storage-sweep'),
|
|
117
|
+
schedule: cronSchedule,
|
|
118
|
+
timezone: v.literal('UTC'),
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
}),
|
|
122
|
+
})
|
|
135
123
|
|
|
136
124
|
function sortBy<T>(entries: readonly T[], key: (entry: T) => string): T[] {
|
|
137
125
|
return [...entries].sort((left, right) => key(left).localeCompare(key(right)))
|
|
@@ -161,14 +149,19 @@ function describeTables(schema: Record<string, unknown>) {
|
|
|
161
149
|
}
|
|
162
150
|
|
|
163
151
|
function describeSection(
|
|
164
|
-
section: Record<string,
|
|
152
|
+
section: Record<string, StandardSchemaV1> | undefined,
|
|
165
153
|
scope: ManifestEnvVar['scope'],
|
|
166
154
|
): ManifestEnvVar[] {
|
|
167
|
-
return Object.entries(section ?? {}).map(([key, schema]) =>
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
155
|
+
return Object.entries(section ?? {}).map(([key, schema]) => {
|
|
156
|
+
let required = false
|
|
157
|
+
try {
|
|
158
|
+
validateStandardSchema(schema, undefined, 'env')
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (!(error instanceof StandardSchemaValidationError)) throw error
|
|
161
|
+
required = true
|
|
162
|
+
}
|
|
163
|
+
return { key, required, scope }
|
|
164
|
+
})
|
|
172
165
|
}
|
|
173
166
|
|
|
174
167
|
function systemTables() {
|
|
@@ -192,7 +185,11 @@ function systemTables() {
|
|
|
192
185
|
}
|
|
193
186
|
|
|
194
187
|
export function parseManifest(value: unknown): BunderstackManifest {
|
|
195
|
-
const manifest =
|
|
188
|
+
const manifest = validateStandardSchema(
|
|
189
|
+
manifestSchema,
|
|
190
|
+
value,
|
|
191
|
+
'manifest',
|
|
192
|
+
) as BunderstackManifest
|
|
196
193
|
rejectDuplicates(
|
|
197
194
|
'database physical table',
|
|
198
195
|
manifest.database.tables.map((entry) => entry.physicalName),
|
package/src/rate-limit.ts
CHANGED
package/src/realtime/facade.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getTableName,
|
|
3
|
+
isTable,
|
|
4
|
+
type InferSelectModel,
|
|
5
|
+
type Table,
|
|
6
|
+
} from 'drizzle-orm'
|
|
2
7
|
|
|
3
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
RealtimeAction,
|
|
10
|
+
RealtimePublisher,
|
|
11
|
+
} from './publisher'
|
|
4
12
|
|
|
5
13
|
export type RealtimeTransport = 'disabled' | 'memory' | 'redis'
|
|
6
14
|
|
|
@@ -22,31 +30,45 @@ export interface RealtimeFacade<
|
|
|
22
30
|
): Promise<void>
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
/**
|
|
34
|
+
* One name for a table everywhere: events, subscriptions, and the CRUD router
|
|
35
|
+
* all use the schema key. Pass the schema so a key like `creditBalances` is not
|
|
36
|
+
* published as its SQL name `credit_balances` — clients subscribe with the key
|
|
37
|
+
* they call procedures with. Without a schema, the SQL name is the only name
|
|
38
|
+
* available and is used as-is.
|
|
39
|
+
*/
|
|
25
40
|
export function createRealtimeFacade<TSchema extends Record<string, unknown>>(
|
|
26
|
-
|
|
27
|
-
transport: RealtimeTransport =
|
|
41
|
+
publisher?: RealtimePublisher,
|
|
42
|
+
transport: RealtimeTransport = publisher ? 'memory' : 'disabled',
|
|
43
|
+
schema?: TSchema,
|
|
28
44
|
): RealtimeFacade<TSchema> {
|
|
29
|
-
if (!
|
|
45
|
+
if (!publisher && transport !== 'disabled') {
|
|
30
46
|
throw new Error(
|
|
31
|
-
'[bunderstack] an enabled realtime transport requires a
|
|
47
|
+
'[bunderstack] an enabled realtime transport requires a publisher',
|
|
32
48
|
)
|
|
33
49
|
}
|
|
34
|
-
if (
|
|
50
|
+
if (publisher && transport === 'disabled') {
|
|
35
51
|
throw new Error(
|
|
36
|
-
'[bunderstack] a realtime
|
|
52
|
+
'[bunderstack] a realtime publisher cannot use the disabled transport',
|
|
37
53
|
)
|
|
38
54
|
}
|
|
39
55
|
|
|
56
|
+
const keyByTableName = new Map<string, string>()
|
|
57
|
+
for (const [key, value] of Object.entries(schema ?? {})) {
|
|
58
|
+
if (isTable(value)) keyByTableName.set(getTableName(value), key)
|
|
59
|
+
}
|
|
60
|
+
|
|
40
61
|
return {
|
|
41
|
-
enabled:
|
|
62
|
+
enabled: publisher !== undefined,
|
|
42
63
|
transport,
|
|
43
64
|
async publish(table, action, record) {
|
|
44
|
-
if (!
|
|
45
|
-
|
|
46
|
-
|
|
65
|
+
if (!publisher) return
|
|
66
|
+
const tableName = getTableName(table)
|
|
67
|
+
await publisher.publish('change', {
|
|
68
|
+
table: keyByTableName.get(tableName) ?? tableName,
|
|
47
69
|
action,
|
|
48
|
-
record as unknown as Record<string, unknown>,
|
|
49
|
-
)
|
|
70
|
+
record: record as unknown as Record<string, unknown>,
|
|
71
|
+
})
|
|
50
72
|
},
|
|
51
73
|
}
|
|
52
74
|
}
|