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.
@@ -1,14 +1,13 @@
1
1
  // src/jobs/define.ts — job definition types and the typed builder.
2
- // `createJobsBuilder` mirrors `createTRPC`: it exists purely to carry
2
+ // `createJobsBuilder` exists purely to carry
3
3
  // TSchema/TEnvResult typing into inline callbacks and extracted files.
4
- import type { ZodType } from 'zod'
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. Handler ctx and tRPC ctx expose this shape;
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
- /** zod schema for the payload; parsed at enqueue AND before the handler runs. */
75
- input?: ZodType<TInput>
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 zod schema. */
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
- // `TDef extends { input: ZodType<infer I> }` fails structurally because
264
- // `input?: ZodType<TInput>` desugars to `ZodType<TInput> | undefined`, which
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 ? null : def.input ? def.input.parse(input) : null
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 =
@@ -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 ? def.input.parse(raw) : undefined
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 a comma-separated `IN` filter. */
24
+ /** Caps both `?limit=` and the number of values in an `IN` filter. */
25
25
  export const MAX_LIST_LIMIT = 200
26
26
 
27
- export const RESERVED_LIST_PARAMS = new Set([
28
- 'limit',
29
- 'offset',
30
- 'sort',
31
- 'order',
32
- 'q',
33
- 'cursor',
34
- 'count',
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
- function parseBoolean(value: string | undefined): boolean {
85
- if (!value) return false
86
- const v = value.toLowerCase()
87
- return v === 'true' || v === '1'
88
- }
89
-
90
- function parseLimit(raw: string | undefined): number {
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 params = url.searchParams
121
- const limit = parseLimit(params.get('limit') ?? undefined)
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 = params.get('sort')?.trim() || access.defaultSort.column
133
- const order = params.has('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
- function coerceFilterValue(
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: unknown,
158
+ raw: string | number | null,
198
159
  ): unknown {
199
160
  if (raw === null) return null
200
161
  const col = getTableColumns(table)[columnName]
201
- if (!col) return raw
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, raw] of Object.entries(filters)) {
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 = coerceFilterValue(table, sortColName, cursor.v)
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 { z, type ZodType } from 'zod'
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 = z.string().min(1)
47
- const migrationDirectory = nonEmpty.refine(
48
- (value) =>
49
- value.startsWith('/') ||
50
- (!value.includes('\\') &&
51
- value.split('/').every((part) => part !== '..' && part !== '')),
52
- {
53
- message:
54
- 'migrationsDirectory must be an absolute path or a relative path without traversal',
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 = nonEmpty.refine(
58
- (value) => {
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 = z
70
- .object({
71
- version: z.literal(3),
72
- database: z
73
- .object({
74
- dialect: z.enum(['sqlite', 'pg']),
75
- migrationsDirectory: migrationDirectory,
76
- tables: z.array(
77
- z
78
- .object({
79
- exportName: nonEmpty,
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
- background: z
111
- .object({
112
- jobs: z.array(z.object({ name: nonEmpty }).strict()),
113
- cron: z.array(
114
- z
115
- .object({
116
- name: nonEmpty,
117
- schedule: cronSchedule,
118
- timezone: z.literal('UTC'),
119
- })
120
- .strict(),
121
- ),
122
- maintenance: z.array(
123
- z
124
- .object({
125
- name: z.literal('storage-sweep'),
126
- schedule: cronSchedule,
127
- timezone: z.literal('UTC'),
128
- })
129
- .strict(),
130
- ),
131
- })
132
- .strict(),
133
- })
134
- .strict()
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, ZodType> | undefined,
152
+ section: Record<string, StandardSchemaV1> | undefined,
165
153
  scope: ManifestEnvVar['scope'],
166
154
  ): ManifestEnvVar[] {
167
- return Object.entries(section ?? {}).map(([key, schema]) => ({
168
- key,
169
- required: !schema.safeParse(undefined).success,
170
- scope,
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 = manifestSchema.parse(value) as BunderstackManifest
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
@@ -54,7 +54,7 @@ export function createRateLimiter(
54
54
  return new Response(
55
55
  JSON.stringify({
56
56
  error: 'Too many requests',
57
- code: 'RATE_LIMITED',
57
+ code: 'TOO_MANY_REQUESTS',
58
58
  }),
59
59
  {
60
60
  status: 429,
@@ -1,6 +1,14 @@
1
- import { getTableName, type InferSelectModel, type Table } from 'drizzle-orm'
1
+ import {
2
+ getTableName,
3
+ isTable,
4
+ type InferSelectModel,
5
+ type Table,
6
+ } from 'drizzle-orm'
2
7
 
3
- import type { RealtimeAction, RealtimeBroker } from './index'
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
- broker?: RealtimeBroker,
27
- transport: RealtimeTransport = broker ? 'memory' : 'disabled',
41
+ publisher?: RealtimePublisher,
42
+ transport: RealtimeTransport = publisher ? 'memory' : 'disabled',
43
+ schema?: TSchema,
28
44
  ): RealtimeFacade<TSchema> {
29
- if (!broker && transport !== 'disabled') {
45
+ if (!publisher && transport !== 'disabled') {
30
46
  throw new Error(
31
- '[bunderstack] an enabled realtime transport requires a broker',
47
+ '[bunderstack] an enabled realtime transport requires a publisher',
32
48
  )
33
49
  }
34
- if (broker && transport === 'disabled') {
50
+ if (publisher && transport === 'disabled') {
35
51
  throw new Error(
36
- '[bunderstack] a realtime broker cannot use the disabled transport',
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: broker !== undefined,
62
+ enabled: publisher !== undefined,
42
63
  transport,
43
64
  async publish(table, action, record) {
44
- if (!broker) return
45
- await broker.publish(
46
- getTableName(table),
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
  }