bunderstack 0.1.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/LICENSE +21 -0
- package/README.md +44 -0
- package/package.json +69 -0
- package/src/access.ts +516 -0
- package/src/auth.ts +102 -0
- package/src/config.ts +152 -0
- package/src/crud.ts +389 -0
- package/src/db.ts +10 -0
- package/src/email.ts +200 -0
- package/src/env.ts +153 -0
- package/src/errors.ts +51 -0
- package/src/handler.ts +53 -0
- package/src/idempotency.ts +102 -0
- package/src/index.ts +368 -0
- package/src/internal-tables.ts +87 -0
- package/src/list-query.ts +413 -0
- package/src/provision.ts +46 -0
- package/src/rate-limit.ts +71 -0
- package/src/realtime/index.ts +245 -0
- package/src/realtime/redis.ts +204 -0
- package/src/schema-export.ts +4 -0
- package/src/scope.ts +17 -0
- package/src/storage/buckets.ts +273 -0
- package/src/storage/delete.ts +27 -0
- package/src/storage/file-meta.ts +224 -0
- package/src/storage/index.ts +31 -0
- package/src/storage/local.ts +69 -0
- package/src/storage/registry.ts +40 -0
- package/src/storage/router.ts +532 -0
- package/src/storage/s3.ts +93 -0
- package/src/storage/sweep.ts +26 -0
- package/src/storage/thumbnails.ts +65 -0
- package/src/trpc.ts +52 -0
- package/src/typeid.ts +116 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// packages/bunderstack/src/realtime.ts
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
checkAccessSync,
|
|
6
|
+
resolveSession,
|
|
7
|
+
rowMatchesScope,
|
|
8
|
+
type AccessUser,
|
|
9
|
+
type AuthSessionResolver,
|
|
10
|
+
type ResolvedAccess,
|
|
11
|
+
type ResolvedTableAccess,
|
|
12
|
+
} from '../access'
|
|
13
|
+
|
|
14
|
+
export type RealtimeAction = 'create' | 'update' | 'delete'
|
|
15
|
+
|
|
16
|
+
type Subscriber = {
|
|
17
|
+
id: string
|
|
18
|
+
send: (data: string) => void
|
|
19
|
+
user: AccessUser | null
|
|
20
|
+
activeOrganizationId: string | null
|
|
21
|
+
subscriptions: Set<string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type BufferedEvent = {
|
|
25
|
+
eventId: number
|
|
26
|
+
table: string
|
|
27
|
+
action: RealtimeAction
|
|
28
|
+
record: Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type RealtimeContextBody = {
|
|
32
|
+
clientId: string
|
|
33
|
+
subscriptions: string[]
|
|
34
|
+
since?: number | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type RealtimeBroker = {
|
|
38
|
+
register(send: (data: string) => void): { id: string }
|
|
39
|
+
setContext(
|
|
40
|
+
id: string,
|
|
41
|
+
ctx: {
|
|
42
|
+
user: AccessUser | null
|
|
43
|
+
activeOrganizationId: string | null
|
|
44
|
+
subscriptions: Set<string>
|
|
45
|
+
since?: number | null
|
|
46
|
+
},
|
|
47
|
+
): { gap: boolean } | Promise<{ gap: boolean }>
|
|
48
|
+
unregister(id: string): void
|
|
49
|
+
publish(
|
|
50
|
+
table: string,
|
|
51
|
+
action: RealtimeAction,
|
|
52
|
+
record: Record<string, unknown>,
|
|
53
|
+
): void | Promise<void>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function tableEntry(
|
|
57
|
+
access: ResolvedAccess,
|
|
58
|
+
tableName: string,
|
|
59
|
+
): ResolvedTableAccess | undefined {
|
|
60
|
+
for (const entry of access.values()) {
|
|
61
|
+
if (entry.tableName === tableName) return entry
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
67
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isRealtimeContextBody(value: unknown): value is RealtimeContextBody {
|
|
71
|
+
if (!isRecord(value)) return false
|
|
72
|
+
return (
|
|
73
|
+
typeof value.clientId === 'string' &&
|
|
74
|
+
Array.isArray(value.subscriptions) &&
|
|
75
|
+
value.subscriptions.every((item) => typeof item === 'string') &&
|
|
76
|
+
(value.since === undefined ||
|
|
77
|
+
value.since === null ||
|
|
78
|
+
typeof value.since === 'number')
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function scopeOk(
|
|
83
|
+
entry: ResolvedTableAccess,
|
|
84
|
+
ctx: Parameters<typeof checkAccessSync>[1],
|
|
85
|
+
record: Record<string, unknown>,
|
|
86
|
+
): boolean {
|
|
87
|
+
if (!entry.scope) return true
|
|
88
|
+
return rowMatchesScope(record, entry.scope(ctx))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function buildRealtimeRouter(
|
|
92
|
+
broker: RealtimeBroker,
|
|
93
|
+
opts: { auth?: AuthSessionResolver; keepaliveMs?: number },
|
|
94
|
+
): Hono {
|
|
95
|
+
const router = new Hono()
|
|
96
|
+
const keepaliveMs = opts.keepaliveMs ?? 30000
|
|
97
|
+
|
|
98
|
+
router.get('/realtime', () => {
|
|
99
|
+
const encoder = new TextEncoder()
|
|
100
|
+
let handle: { id: string }
|
|
101
|
+
let keepalive: ReturnType<typeof setInterval>
|
|
102
|
+
|
|
103
|
+
const stream = new ReadableStream({
|
|
104
|
+
start(controller) {
|
|
105
|
+
const send = (data: string) =>
|
|
106
|
+
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
|
107
|
+
handle = broker.register(send)
|
|
108
|
+
send(JSON.stringify({ clientId: handle.id }))
|
|
109
|
+
keepalive = setInterval(
|
|
110
|
+
() => controller.enqueue(encoder.encode(': ping\n\n')),
|
|
111
|
+
keepaliveMs,
|
|
112
|
+
)
|
|
113
|
+
},
|
|
114
|
+
cancel() {
|
|
115
|
+
clearInterval(keepalive)
|
|
116
|
+
broker.unregister(handle.id)
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
return new Response(stream, {
|
|
121
|
+
headers: {
|
|
122
|
+
'Content-Type': 'text/event-stream',
|
|
123
|
+
'Cache-Control': 'no-cache',
|
|
124
|
+
Connection: 'keep-alive',
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
router.post('/realtime', async (c) => {
|
|
130
|
+
const body = await c.req.json().catch(() => null)
|
|
131
|
+
if (!isRealtimeContextBody(body)) {
|
|
132
|
+
return c.json({ error: 'clientId and subscriptions required' }, 400)
|
|
133
|
+
}
|
|
134
|
+
const { user, activeOrganizationId } = await resolveSession(
|
|
135
|
+
opts.auth,
|
|
136
|
+
c.req.raw.headers,
|
|
137
|
+
)
|
|
138
|
+
const { gap } = await broker.setContext(body.clientId, {
|
|
139
|
+
user,
|
|
140
|
+
activeOrganizationId,
|
|
141
|
+
subscriptions: new Set(body.subscriptions),
|
|
142
|
+
since: body.since ?? null,
|
|
143
|
+
})
|
|
144
|
+
return c.json({ gap }, 200)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
return router
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createRealtimeBroker(opts: {
|
|
151
|
+
access: ResolvedAccess
|
|
152
|
+
bufferSize?: number
|
|
153
|
+
}): RealtimeBroker {
|
|
154
|
+
const subscribers = new Map<string, Subscriber>()
|
|
155
|
+
const bufferSize = opts.bufferSize ?? 1000
|
|
156
|
+
const buffer: BufferedEvent[] = []
|
|
157
|
+
let nextId = 1
|
|
158
|
+
|
|
159
|
+
// Returns true when this subscriber should receive this record (topic + access + scope).
|
|
160
|
+
function deliverable(
|
|
161
|
+
s: Subscriber,
|
|
162
|
+
table: string,
|
|
163
|
+
record: Record<string, unknown>,
|
|
164
|
+
id: unknown,
|
|
165
|
+
): boolean {
|
|
166
|
+
const entry = tableEntry(opts.access, table)
|
|
167
|
+
if (!entry) return false
|
|
168
|
+
const topicMatch =
|
|
169
|
+
s.subscriptions.has(table) ||
|
|
170
|
+
(id != null && s.subscriptions.has(`${table}/${String(id)}`))
|
|
171
|
+
if (!topicMatch) return false
|
|
172
|
+
const ctx = {
|
|
173
|
+
user: s.user,
|
|
174
|
+
request: new Request('http://realtime.local'),
|
|
175
|
+
row: record,
|
|
176
|
+
session: { activeOrganizationId: s.activeOrganizationId },
|
|
177
|
+
}
|
|
178
|
+
if (typeof entry.get === 'function') return false // function get-rules unsupported on realtime v1
|
|
179
|
+
if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
|
|
180
|
+
return false
|
|
181
|
+
if (!scopeOk(entry, ctx, record)) return false
|
|
182
|
+
return true
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
register(send) {
|
|
187
|
+
const id = crypto.randomUUID()
|
|
188
|
+
subscribers.set(id, {
|
|
189
|
+
id,
|
|
190
|
+
send,
|
|
191
|
+
user: null,
|
|
192
|
+
activeOrganizationId: null,
|
|
193
|
+
subscriptions: new Set(),
|
|
194
|
+
})
|
|
195
|
+
return { id }
|
|
196
|
+
},
|
|
197
|
+
setContext(id, ctx) {
|
|
198
|
+
const s = subscribers.get(id)
|
|
199
|
+
if (!s) return { gap: false }
|
|
200
|
+
s.user = ctx.user
|
|
201
|
+
s.activeOrganizationId = ctx.activeOrganizationId
|
|
202
|
+
s.subscriptions = ctx.subscriptions
|
|
203
|
+
|
|
204
|
+
const since = ctx.since ?? null
|
|
205
|
+
if (since == null) return { gap: false } // fresh client; current data already loaded by queries
|
|
206
|
+
|
|
207
|
+
const maxId = nextId - 1
|
|
208
|
+
// since ahead of anything we issued => server restarted / different epoch => full catch-up.
|
|
209
|
+
if (since > maxId) return { gap: true }
|
|
210
|
+
const oldest = buffer.length ? buffer[0]!.eventId : nextId
|
|
211
|
+
const gap = since < oldest - 1 // events between since and oldest were evicted
|
|
212
|
+
for (const e of buffer) {
|
|
213
|
+
if (e.eventId <= since) continue
|
|
214
|
+
if (!deliverable(s, e.table, e.record, e.record['id'])) continue
|
|
215
|
+
s.send(
|
|
216
|
+
JSON.stringify({
|
|
217
|
+
eventId: e.eventId,
|
|
218
|
+
action: e.action,
|
|
219
|
+
table: e.table,
|
|
220
|
+
record: e.record,
|
|
221
|
+
}),
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
return { gap }
|
|
225
|
+
},
|
|
226
|
+
unregister(id) {
|
|
227
|
+
subscribers.delete(id)
|
|
228
|
+
},
|
|
229
|
+
publish(table, action, record) {
|
|
230
|
+
const entry = tableEntry(opts.access, table)
|
|
231
|
+
if (!entry) return
|
|
232
|
+
const eventId = nextId++
|
|
233
|
+
buffer.push({ eventId, table, action, record })
|
|
234
|
+
if (buffer.length > bufferSize) buffer.shift()
|
|
235
|
+
const id = record['id']
|
|
236
|
+
const payload = JSON.stringify({ eventId, action, table, record })
|
|
237
|
+
for (const s of subscribers.values()) {
|
|
238
|
+
if (!deliverable(s, table, record, id)) continue
|
|
239
|
+
s.send(payload)
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const createMemoryRealtimeBroker = createRealtimeBroker
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { RealtimeAction, RealtimeBroker } from './index'
|
|
2
|
+
|
|
3
|
+
// packages/bunderstack/src/realtime-redis.ts
|
|
4
|
+
//
|
|
5
|
+
// Redis-backed realtime broker: cross-instance fan-out + persistent replay log.
|
|
6
|
+
//
|
|
7
|
+
// Fan-out model: every instance SUBSCRIBEs one channel. publish() INCRs a global
|
|
8
|
+
// counter (monotonic eventId across instances/restarts), LPUSH+LTRIM a capped log
|
|
9
|
+
// for replay, then PUBLISHes. Redis delivers the message to ALL subscribers
|
|
10
|
+
// including the publisher, so local delivery happens uniformly inside the channel
|
|
11
|
+
// listener — never directly in publish() — to avoid double-delivery.
|
|
12
|
+
import {
|
|
13
|
+
checkAccessSync,
|
|
14
|
+
rowMatchesScope,
|
|
15
|
+
type AccessUser,
|
|
16
|
+
type ResolvedAccess,
|
|
17
|
+
type ResolvedTableAccess,
|
|
18
|
+
} from '../access'
|
|
19
|
+
|
|
20
|
+
export type RedisLike = {
|
|
21
|
+
incr(key: string): Promise<number>
|
|
22
|
+
publish(channel: string, message: string): Promise<unknown>
|
|
23
|
+
subscribe(
|
|
24
|
+
channel: string,
|
|
25
|
+
listener: (message: string) => void,
|
|
26
|
+
): Promise<unknown>
|
|
27
|
+
lpush(key: string, value: string): Promise<unknown>
|
|
28
|
+
ltrim(key: string, start: number, stop: number): Promise<unknown>
|
|
29
|
+
lrange(key: string, start: number, stop: number): Promise<string[]>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Subscriber = {
|
|
33
|
+
id: string
|
|
34
|
+
send: (data: string) => void
|
|
35
|
+
user: AccessUser | null
|
|
36
|
+
activeOrganizationId: string | null
|
|
37
|
+
subscriptions: Set<string>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type WireEvent = {
|
|
41
|
+
eventId: number
|
|
42
|
+
table: string
|
|
43
|
+
action: RealtimeAction
|
|
44
|
+
record: Record<string, unknown>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tableEntry(
|
|
48
|
+
access: ResolvedAccess,
|
|
49
|
+
name: string,
|
|
50
|
+
): ResolvedTableAccess | undefined {
|
|
51
|
+
for (const entry of access.values())
|
|
52
|
+
if (entry.tableName === name) return entry
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
57
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isRealtimeAction(value: unknown): value is RealtimeAction {
|
|
61
|
+
return value === 'create' || value === 'update' || value === 'delete'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseWireEvent(raw: string): WireEvent | null {
|
|
65
|
+
try {
|
|
66
|
+
const value = JSON.parse(raw)
|
|
67
|
+
if (
|
|
68
|
+
!isRecord(value) ||
|
|
69
|
+
typeof value.eventId !== 'number' ||
|
|
70
|
+
typeof value.table !== 'string' ||
|
|
71
|
+
!isRealtimeAction(value.action) ||
|
|
72
|
+
!isRecord(value.record)
|
|
73
|
+
) {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
eventId: value.eventId,
|
|
78
|
+
table: value.table,
|
|
79
|
+
action: value.action,
|
|
80
|
+
record: value.record,
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createRedisRealtimeBroker(opts: {
|
|
88
|
+
access: ResolvedAccess
|
|
89
|
+
redis: RedisLike
|
|
90
|
+
bufferSize?: number
|
|
91
|
+
channel?: string
|
|
92
|
+
}): RealtimeBroker & { ready: Promise<void> } {
|
|
93
|
+
const subscribers = new Map<string, Subscriber>()
|
|
94
|
+
const bufferSize = opts.bufferSize ?? 1000
|
|
95
|
+
const channel = opts.channel ?? 'bunderstack:realtime'
|
|
96
|
+
const logKey = `${channel}:log`
|
|
97
|
+
const counterKey = `${channel}:seq`
|
|
98
|
+
|
|
99
|
+
function deliverable(
|
|
100
|
+
s: Subscriber,
|
|
101
|
+
table: string,
|
|
102
|
+
record: Record<string, unknown>,
|
|
103
|
+
): boolean {
|
|
104
|
+
const entry = tableEntry(opts.access, table)
|
|
105
|
+
if (!entry) return false
|
|
106
|
+
const id = record['id']
|
|
107
|
+
const topicMatch =
|
|
108
|
+
s.subscriptions.has(table) ||
|
|
109
|
+
(id != null && s.subscriptions.has(`${table}/${String(id)}`))
|
|
110
|
+
if (!topicMatch) return false
|
|
111
|
+
const ctx = {
|
|
112
|
+
user: s.user,
|
|
113
|
+
request: new Request('http://realtime.local'),
|
|
114
|
+
row: record,
|
|
115
|
+
session: { activeOrganizationId: s.activeOrganizationId },
|
|
116
|
+
}
|
|
117
|
+
if (typeof entry.get === 'function') return false
|
|
118
|
+
if (!checkAccessSync(entry.get, ctx, entry.ownerColumn).allowed)
|
|
119
|
+
return false
|
|
120
|
+
if (entry.scope && !rowMatchesScope(record, entry.scope(ctx))) return false
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function fanOut(evt: WireEvent) {
|
|
125
|
+
const payload = JSON.stringify(evt)
|
|
126
|
+
for (const s of subscribers.values()) {
|
|
127
|
+
if (deliverable(s, evt.table, evt.record)) s.send(payload)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Subscribe once; all local delivery happens here.
|
|
132
|
+
const ready = opts.redis
|
|
133
|
+
.subscribe(channel, (message) => {
|
|
134
|
+
const evt = parseWireEvent(message)
|
|
135
|
+
if (evt) fanOut(evt)
|
|
136
|
+
})
|
|
137
|
+
.then(() => undefined)
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
ready,
|
|
141
|
+
register(send) {
|
|
142
|
+
const id = crypto.randomUUID()
|
|
143
|
+
subscribers.set(id, {
|
|
144
|
+
id,
|
|
145
|
+
send,
|
|
146
|
+
user: null,
|
|
147
|
+
activeOrganizationId: null,
|
|
148
|
+
subscriptions: new Set(),
|
|
149
|
+
})
|
|
150
|
+
return { id }
|
|
151
|
+
},
|
|
152
|
+
async setContext(id, ctx) {
|
|
153
|
+
const s = subscribers.get(id)
|
|
154
|
+
if (!s) return { gap: false }
|
|
155
|
+
s.user = ctx.user
|
|
156
|
+
s.activeOrganizationId = ctx.activeOrganizationId
|
|
157
|
+
s.subscriptions = ctx.subscriptions
|
|
158
|
+
|
|
159
|
+
const since = ctx.since ?? null
|
|
160
|
+
if (since == null) return { gap: false }
|
|
161
|
+
|
|
162
|
+
// Log is LPUSH-ed (newest first); read newest->oldest, filter id>since.
|
|
163
|
+
// If redis is unavailable, return { gap: true } so the client falls back
|
|
164
|
+
// to a full refetch rather than 500-ing the POST handler.
|
|
165
|
+
let raw: string[]
|
|
166
|
+
try {
|
|
167
|
+
raw = await opts.redis.lrange(logKey, 0, bufferSize - 1)
|
|
168
|
+
} catch {
|
|
169
|
+
return { gap: true }
|
|
170
|
+
}
|
|
171
|
+
const events = raw
|
|
172
|
+
.map(parseWireEvent)
|
|
173
|
+
.filter((e) => e !== null)
|
|
174
|
+
.filter((e) => e.eventId > since)
|
|
175
|
+
.sort((a, b) => a.eventId - b.eventId)
|
|
176
|
+
|
|
177
|
+
const oldestInLog = events.length ? events[0]!.eventId : since + 1
|
|
178
|
+
const gap = oldestInLog > since + 1
|
|
179
|
+
for (const e of events) {
|
|
180
|
+
if (deliverable(s, e.table, e.record)) s.send(JSON.stringify(e))
|
|
181
|
+
}
|
|
182
|
+
return { gap }
|
|
183
|
+
},
|
|
184
|
+
unregister(id) {
|
|
185
|
+
subscribers.delete(id)
|
|
186
|
+
},
|
|
187
|
+
async publish(table, action, record) {
|
|
188
|
+
if (!tableEntry(opts.access, table)) return
|
|
189
|
+
try {
|
|
190
|
+
const eventId = await opts.redis.incr(counterKey)
|
|
191
|
+
const evt: WireEvent = { eventId, table, action, record }
|
|
192
|
+
const msg = JSON.stringify(evt)
|
|
193
|
+
await opts.redis.lpush(logKey, msg)
|
|
194
|
+
await opts.redis.ltrim(logKey, 0, bufferSize - 1)
|
|
195
|
+
await opts.redis.publish(channel, msg)
|
|
196
|
+
} catch {
|
|
197
|
+
// Broadcast is best-effort: a redis blip must not reject the floating
|
|
198
|
+
// promise (callers use `void broker?.publish(...)` with no .catch).
|
|
199
|
+
// Correctness self-heals: reconnecting clients use the since/gap replay
|
|
200
|
+
// path which issues a full refetch when events were missed.
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { and, eq, getTableColumns, inArray, type SQL } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { ScopeMap } from './access'
|
|
4
|
+
|
|
5
|
+
export function buildScopeWhere(
|
|
6
|
+
table: Parameters<typeof getTableColumns>[0],
|
|
7
|
+
scope: ScopeMap,
|
|
8
|
+
): SQL | undefined {
|
|
9
|
+
const columns = getTableColumns(table)
|
|
10
|
+
const conditions: SQL[] = []
|
|
11
|
+
for (const [name, value] of Object.entries(scope)) {
|
|
12
|
+
const col = columns[name]
|
|
13
|
+
if (!col) continue
|
|
14
|
+
conditions.push(Array.isArray(value) ? inArray(col, value) : eq(col, value))
|
|
15
|
+
}
|
|
16
|
+
return conditions.length ? and(...conditions) : undefined
|
|
17
|
+
}
|