bunderstack 0.16.0 → 0.17.0-beta.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 +25 -138
- package/package.json +21 -14
- package/src/access.ts +6 -1
- package/src/api/api-types.types.ts +106 -0
- package/src/api/builder.ts +52 -0
- package/src/api/context.ts +83 -0
- package/src/api/crud-router.ts +321 -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 +84 -0
- package/src/auth.ts +5 -0
- package/src/blueprint.ts +88 -105
- package/src/config.ts +57 -39
- package/src/crud-operations.ts +488 -0
- package/src/dialect.ts +1 -1
- package/src/env.ts +19 -13
- package/src/errors.ts +90 -23
- package/src/handler.ts +16 -44
- package/src/index.ts +235 -246
- package/src/jobs/define.ts +8 -10
- package/src/jobs/queue.ts +6 -2
- package/src/jobs/worker.ts +4 -1
- package/src/manifest.ts +84 -87
- package/src/realtime/facade.ts +16 -13
- package/src/realtime/filter.ts +77 -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/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/realtime/facade.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { getTableName, type InferSelectModel, type Table } from 'drizzle-orm'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
RealtimeAction,
|
|
5
|
+
RealtimePublisher,
|
|
6
|
+
} from './publisher'
|
|
4
7
|
|
|
5
8
|
export type RealtimeTransport = 'disabled' | 'memory' | 'redis'
|
|
6
9
|
|
|
@@ -23,30 +26,30 @@ export interface RealtimeFacade<
|
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
export function createRealtimeFacade<TSchema extends Record<string, unknown>>(
|
|
26
|
-
|
|
27
|
-
transport: RealtimeTransport =
|
|
29
|
+
publisher?: RealtimePublisher,
|
|
30
|
+
transport: RealtimeTransport = publisher ? 'memory' : 'disabled',
|
|
28
31
|
): RealtimeFacade<TSchema> {
|
|
29
|
-
if (!
|
|
32
|
+
if (!publisher && transport !== 'disabled') {
|
|
30
33
|
throw new Error(
|
|
31
|
-
'[bunderstack] an enabled realtime transport requires a
|
|
34
|
+
'[bunderstack] an enabled realtime transport requires a publisher',
|
|
32
35
|
)
|
|
33
36
|
}
|
|
34
|
-
if (
|
|
37
|
+
if (publisher && transport === 'disabled') {
|
|
35
38
|
throw new Error(
|
|
36
|
-
'[bunderstack] a realtime
|
|
39
|
+
'[bunderstack] a realtime publisher cannot use the disabled transport',
|
|
37
40
|
)
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
return {
|
|
41
|
-
enabled:
|
|
44
|
+
enabled: publisher !== undefined,
|
|
42
45
|
transport,
|
|
43
46
|
async publish(table, action, record) {
|
|
44
|
-
if (!
|
|
45
|
-
await
|
|
46
|
-
getTableName(table),
|
|
47
|
+
if (!publisher) return
|
|
48
|
+
await publisher.publish('change', {
|
|
49
|
+
table: getTableName(table),
|
|
47
50
|
action,
|
|
48
|
-
record as unknown as Record<string, unknown>,
|
|
49
|
-
)
|
|
51
|
+
record: record as unknown as Record<string, unknown>,
|
|
52
|
+
})
|
|
50
53
|
},
|
|
51
54
|
}
|
|
52
55
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getEventMeta, withEventMeta } from '@orpc/server'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AccessUser,
|
|
5
|
+
ResolvedAccess,
|
|
6
|
+
} from '../access'
|
|
7
|
+
import type { RealtimeChange } from './publisher'
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
checkAccess,
|
|
11
|
+
rowMatchesScope,
|
|
12
|
+
tableEntryForName,
|
|
13
|
+
} from '../access'
|
|
14
|
+
|
|
15
|
+
export interface FilterRealtimeChangesOptions {
|
|
16
|
+
subscriptions: readonly string[]
|
|
17
|
+
access: ResolvedAccess
|
|
18
|
+
request: Request
|
|
19
|
+
getSession: () => Promise<{
|
|
20
|
+
user: AccessUser | null
|
|
21
|
+
activeOrganizationId: string | null
|
|
22
|
+
}>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function* filterRealtimeChanges(
|
|
26
|
+
source: AsyncIterable<RealtimeChange>,
|
|
27
|
+
options: FilterRealtimeChangesOptions,
|
|
28
|
+
): AsyncGenerator<RealtimeChange, void, void> {
|
|
29
|
+
const subscriptions = new Set(options.subscriptions)
|
|
30
|
+
let sessionPromise:
|
|
31
|
+
| ReturnType<FilterRealtimeChangesOptions['getSession']>
|
|
32
|
+
| undefined
|
|
33
|
+
const getSession = () => (sessionPromise ??= options.getSession())
|
|
34
|
+
|
|
35
|
+
for await (const change of source) {
|
|
36
|
+
const entry = tableEntryForName(options.access, change.table)
|
|
37
|
+
if (!entry?.enabled) continue
|
|
38
|
+
|
|
39
|
+
const recordId = change.record.id
|
|
40
|
+
if (
|
|
41
|
+
!subscriptions.has(change.table) &&
|
|
42
|
+
(recordId == null ||
|
|
43
|
+
!subscriptions.has(`${change.table}/${String(recordId)}`))
|
|
44
|
+
) {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (entry.get === 'deny') continue
|
|
48
|
+
|
|
49
|
+
const needsSession = entry.get !== 'public' || entry.readScope !== undefined
|
|
50
|
+
const session = needsSession
|
|
51
|
+
? await getSession()
|
|
52
|
+
: { user: null, activeOrganizationId: null }
|
|
53
|
+
const context = {
|
|
54
|
+
request: options.request,
|
|
55
|
+
user: session.user,
|
|
56
|
+
row: change.record,
|
|
57
|
+
session: { activeOrganizationId: session.activeOrganizationId },
|
|
58
|
+
}
|
|
59
|
+
if (!(await checkAccess(entry.get, context, entry.ownerColumn)).allowed) {
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
entry.readScope &&
|
|
64
|
+
!rowMatchesScope(change.record, entry.readScope(context))
|
|
65
|
+
) {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const projected: RealtimeChange = {
|
|
70
|
+
table: change.table,
|
|
71
|
+
action: change.action,
|
|
72
|
+
record: change.record,
|
|
73
|
+
}
|
|
74
|
+
const meta = getEventMeta(change)
|
|
75
|
+
yield meta ? withEventMeta(projected, meta) : projected
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export const REALTIME_HEARTBEAT_INTERVAL_MS = 5_000
|
|
2
|
+
|
|
3
|
+
export type RealtimeHeartbeat = { type: 'heartbeat' }
|
|
4
|
+
|
|
5
|
+
type SourceState<T> =
|
|
6
|
+
| { status: 'pending' }
|
|
7
|
+
| { status: 'ready'; result: IteratorResult<T> }
|
|
8
|
+
| { status: 'error'; error: unknown }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Emits a transport-only event whenever the source has been idle for an
|
|
12
|
+
* interval. Heartbeats are deliberately not published, persisted, or assigned
|
|
13
|
+
* event IDs, so they do not affect replay and resume semantics.
|
|
14
|
+
*/
|
|
15
|
+
export async function* withRealtimeHeartbeat<T>(
|
|
16
|
+
source: AsyncIterable<T>,
|
|
17
|
+
options: { intervalMs?: number; signal?: AbortSignal },
|
|
18
|
+
): AsyncGenerator<T | RealtimeHeartbeat, void, void> {
|
|
19
|
+
const intervalMs = Math.max(
|
|
20
|
+
1,
|
|
21
|
+
options.intervalMs ?? REALTIME_HEARTBEAT_INTERVAL_MS,
|
|
22
|
+
)
|
|
23
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
24
|
+
let state: SourceState<T> = { status: 'pending' }
|
|
25
|
+
let wake: (() => void) | undefined
|
|
26
|
+
const getState = (): SourceState<T> => state
|
|
27
|
+
|
|
28
|
+
const requestNext = () => {
|
|
29
|
+
state = { status: 'pending' }
|
|
30
|
+
void iterator.next().then(
|
|
31
|
+
(result) => {
|
|
32
|
+
state = { status: 'ready', result }
|
|
33
|
+
wake?.()
|
|
34
|
+
},
|
|
35
|
+
(error: unknown) => {
|
|
36
|
+
state = { status: 'error', error }
|
|
37
|
+
wake?.()
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
requestNext()
|
|
43
|
+
try {
|
|
44
|
+
while (!options.signal?.aborted) {
|
|
45
|
+
let current = getState()
|
|
46
|
+
if (current.status === 'pending') {
|
|
47
|
+
await new Promise<void>((resolve) => {
|
|
48
|
+
let settled = false
|
|
49
|
+
const finish = () => {
|
|
50
|
+
if (settled) return
|
|
51
|
+
settled = true
|
|
52
|
+
clearTimeout(timer)
|
|
53
|
+
options.signal?.removeEventListener('abort', finish)
|
|
54
|
+
resolve()
|
|
55
|
+
}
|
|
56
|
+
const timer = setTimeout(finish, intervalMs)
|
|
57
|
+
options.signal?.addEventListener('abort', finish, { once: true })
|
|
58
|
+
wake = finish
|
|
59
|
+
})
|
|
60
|
+
wake = undefined
|
|
61
|
+
|
|
62
|
+
if (options.signal?.aborted) break
|
|
63
|
+
current = getState()
|
|
64
|
+
if (current.status === 'pending') {
|
|
65
|
+
yield { type: 'heartbeat' }
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (current.status === 'error') throw current.error
|
|
71
|
+
if (current.result.done) break
|
|
72
|
+
|
|
73
|
+
yield current.result.value
|
|
74
|
+
requestNext()
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
wake = undefined
|
|
78
|
+
await iterator.return?.()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Publisher } from '@orpc/publisher'
|
|
2
|
+
import type { RedisClient } from 'bun'
|
|
3
|
+
|
|
4
|
+
import { BunRedisPublisher } from '@orpc/bun'
|
|
5
|
+
import { MemoryPublisher } from '@orpc/publisher/memory'
|
|
6
|
+
|
|
7
|
+
export type RealtimeAction = 'create' | 'update' | 'delete'
|
|
8
|
+
|
|
9
|
+
export interface RealtimeChange {
|
|
10
|
+
table: string
|
|
11
|
+
action: RealtimeAction
|
|
12
|
+
record: Record<string, unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RealtimeEvents extends Record<string, object> {
|
|
16
|
+
change: RealtimeChange
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type RealtimePublisher = Publisher<RealtimeEvents>
|
|
20
|
+
|
|
21
|
+
export interface RealtimePublisherOptions {
|
|
22
|
+
maxBufferedEvents?: number
|
|
23
|
+
resumeSeconds?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createMemoryRealtimePublisher(
|
|
27
|
+
options: RealtimePublisherOptions = {},
|
|
28
|
+
): RealtimePublisher {
|
|
29
|
+
return new MemoryPublisher<RealtimeEvents>({
|
|
30
|
+
maxBufferedEvents: options.maxBufferedEvents,
|
|
31
|
+
resume: { enabled: true, seconds: options.resumeSeconds ?? 300 },
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createRedisRealtimePublisher(
|
|
36
|
+
redis: RedisClient,
|
|
37
|
+
subscriber: RedisClient | Promise<RedisClient>,
|
|
38
|
+
options: RealtimePublisherOptions & { prefix?: string } = {},
|
|
39
|
+
): RealtimePublisher {
|
|
40
|
+
return new BunRedisPublisher<RealtimeEvents>(redis, {
|
|
41
|
+
subscriber,
|
|
42
|
+
prefix: options.prefix ?? 'bunderstack:',
|
|
43
|
+
maxBufferedEvents: options.maxBufferedEvents,
|
|
44
|
+
resume: { enabled: true, seconds: options.resumeSeconds ?? 300 },
|
|
45
|
+
})
|
|
46
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
2
|
+
|
|
3
|
+
export type StandardSchema = StandardSchemaV1
|
|
4
|
+
export type InferStandardOutput<TSchema extends StandardSchemaV1> =
|
|
5
|
+
StandardSchemaV1.InferOutput<TSchema>
|
|
6
|
+
|
|
7
|
+
export type StandardSchemaIssue = {
|
|
8
|
+
path: PropertyKey[]
|
|
9
|
+
message: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class StandardSchemaValidationError extends Error {
|
|
13
|
+
readonly issues: StandardSchemaIssue[]
|
|
14
|
+
|
|
15
|
+
constructor(label: string, issues: readonly StandardSchemaV1.Issue[]) {
|
|
16
|
+
const normalized = issues.map((issue) => ({
|
|
17
|
+
path: [...(issue.path ?? [])].map((segment) =>
|
|
18
|
+
typeof segment === 'object' ? segment.key : segment,
|
|
19
|
+
),
|
|
20
|
+
message: issue.message,
|
|
21
|
+
}))
|
|
22
|
+
super(
|
|
23
|
+
normalized
|
|
24
|
+
.map((issue) => {
|
|
25
|
+
const path = issue.path.map(String).join('.')
|
|
26
|
+
return `${label}${path ? `.${path}` : ''}: ${issue.message}`
|
|
27
|
+
})
|
|
28
|
+
.join('\n'),
|
|
29
|
+
)
|
|
30
|
+
this.name = 'StandardSchemaValidationError'
|
|
31
|
+
this.issues = normalized
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
36
|
+
return (
|
|
37
|
+
typeof value === 'object' &&
|
|
38
|
+
value !== null &&
|
|
39
|
+
'then' in value &&
|
|
40
|
+
typeof value.then === 'function'
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateStandardSchema<TSchema extends StandardSchemaV1>(
|
|
45
|
+
schema: TSchema,
|
|
46
|
+
value: unknown,
|
|
47
|
+
label: string,
|
|
48
|
+
): StandardSchemaV1.InferOutput<TSchema> {
|
|
49
|
+
const result = schema['~standard'].validate(value)
|
|
50
|
+
if (isPromiseLike(result)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`[bunderstack] ${label} schema validation must be synchronous`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
if (result.issues) {
|
|
56
|
+
throw new StandardSchemaValidationError(label, result.issues)
|
|
57
|
+
}
|
|
58
|
+
return result.value
|
|
59
|
+
}
|
package/src/storage/index.ts
CHANGED
|
@@ -3,6 +3,14 @@ import { LocalStorageAdapter } from './local'
|
|
|
3
3
|
import { S3StorageAdapter } from './s3'
|
|
4
4
|
|
|
5
5
|
export type { LocalStorageAdapter, S3StorageAdapter }
|
|
6
|
+
export { createStorageOperations } from './operations'
|
|
7
|
+
export type {
|
|
8
|
+
PrepareUploadResult,
|
|
9
|
+
StorageDownload,
|
|
10
|
+
StorageExecutionContext,
|
|
11
|
+
StorageOperations,
|
|
12
|
+
StorageOperationsOptions,
|
|
13
|
+
} from './operations'
|
|
6
14
|
|
|
7
15
|
export interface PresignPutOptions {
|
|
8
16
|
contentType?: string
|