@pikku/kysely 0.13.24 → 0.13.28

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 (80) hide show
  1. package/CHANGELOG.md +271 -0
  2. package/dist/src/index.d.ts +4 -1
  3. package/dist/src/index.js +3 -1
  4. package/dist/src/kysely-analytics-service.d.ts +30 -0
  5. package/dist/src/kysely-analytics-service.js +80 -0
  6. package/dist/src/kysely-feature-flag-store.d.ts +37 -0
  7. package/dist/src/kysely-feature-flag-store.js +239 -0
  8. package/dist/src/kysely-scope-service.d.ts +1 -0
  9. package/dist/src/kysely-scope-service.js +29 -0
  10. package/dist/src/kysely-tables.d.ts +45 -0
  11. package/dist/src/schema/analytics.schema.d.ts +17 -0
  12. package/dist/src/schema/analytics.schema.js +48 -0
  13. package/dist/src/schema/flag.schema.d.ts +16 -0
  14. package/dist/src/schema/flag.schema.js +62 -0
  15. package/dist/src/schema/index.d.ts +2 -0
  16. package/dist/src/schema/index.js +6 -0
  17. package/package.json +11 -6
  18. package/run-tests.sh +9 -1
  19. package/bin/pikku-kysely-pure.ts +0 -59
  20. package/dist/src/kysely-lock-vault.d.ts +0 -34
  21. package/dist/src/kysely-lock-vault.js +0 -77
  22. package/dist/src/schema/data-lock.schema.d.ts +0 -15
  23. package/dist/src/schema/data-lock.schema.js +0 -27
  24. package/dist/tsconfig.tsbuildinfo +0 -1
  25. package/src/classification-crypto.test.ts +0 -143
  26. package/src/classification-crypto.ts +0 -163
  27. package/src/classification-plugin.pglite.test.ts +0 -226
  28. package/src/classification-plugin.test.ts +0 -265
  29. package/src/classification-plugin.ts +0 -249
  30. package/src/coercion-plugin.test.ts +0 -160
  31. package/src/coercion-plugin.ts +0 -187
  32. package/src/create-audited-kysely.test.ts +0 -560
  33. package/src/create-audited-kysely.ts +0 -254
  34. package/src/index.ts +0 -81
  35. package/src/kysely-agent-run-scores.ts +0 -47
  36. package/src/kysely-agent-run-service.ts +0 -244
  37. package/src/kysely-agent-run-state-service.ts +0 -200
  38. package/src/kysely-agent-storage-service.ts +0 -632
  39. package/src/kysely-audit-service.test.ts +0 -352
  40. package/src/kysely-audit-service.ts +0 -237
  41. package/src/kysely-channel-store.ts +0 -107
  42. package/src/kysely-credential-service.ts +0 -286
  43. package/src/kysely-deployment-service.ts +0 -163
  44. package/src/kysely-eventhub-store.ts +0 -53
  45. package/src/kysely-json.ts +0 -5
  46. package/src/kysely-scope-service.test.ts +0 -418
  47. package/src/kysely-scope-service.ts +0 -425
  48. package/src/kysely-secret-service.ts +0 -242
  49. package/src/kysely-services.test.ts +0 -405
  50. package/src/kysely-session-store.ts +0 -57
  51. package/src/kysely-tables.test.ts +0 -110
  52. package/src/kysely-tables.ts +0 -434
  53. package/src/kysely-virtual-user-run-store.test.ts +0 -369
  54. package/src/kysely-virtual-user-run-store.ts +0 -235
  55. package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
  56. package/src/kysely-virtual-user-schedule-store.ts +0 -187
  57. package/src/kysely-webhook-service.test.ts +0 -153
  58. package/src/kysely-webhook-service.ts +0 -139
  59. package/src/kysely-workflow-mirror.test.ts +0 -259
  60. package/src/kysely-workflow-mirror.ts +0 -374
  61. package/src/kysely-workflow-run-service.ts +0 -305
  62. package/src/kysely-workflow-service.test.ts +0 -544
  63. package/src/kysely-workflow-service.ts +0 -820
  64. package/src/schema/agent.schema.ts +0 -162
  65. package/src/schema/audit.schema.ts +0 -57
  66. package/src/schema/channel.schema.ts +0 -38
  67. package/src/schema/credential.schema.ts +0 -58
  68. package/src/schema/deployment.schema.ts +0 -47
  69. package/src/schema/index.ts +0 -441
  70. package/src/schema/pikku-schema.types.ts +0 -169
  71. package/src/schema/schema.test.ts +0 -591
  72. package/src/schema/scope.schema.ts +0 -110
  73. package/src/schema/secret.schema.ts +0 -49
  74. package/src/schema/session.schema.ts +0 -20
  75. package/src/schema/virtual-user-schedule.schema.ts +0 -48
  76. package/src/schema/virtual-user.schema.ts +0 -106
  77. package/src/schema/webhook.schema.ts +0 -58
  78. package/src/schema/workflow.schema.ts +0 -150
  79. package/src/serialize-plugin.ts +0 -233
  80. package/tsconfig.json +0 -17
@@ -1,249 +0,0 @@
1
- import type { KyselyPlugin, RootOperationNode, UnknownRow } from 'kysely'
2
- import type {
3
- ClassificationManifest,
4
- ColumnForm,
5
- } from '@pikku/core/classification'
6
- import {
7
- DEFAULT_KEY_ID,
8
- isColumnEnvelope,
9
- type ClassificationCrypto,
10
- } from './classification-crypto.js'
11
-
12
- /** What the manifest says about one column, reduced to what the query path needs. */
13
- interface EncryptedColumn {
14
- form: Extract<ColumnForm, 'wrapped' | 'sealed'>
15
- keyId: string
16
- }
17
-
18
- interface ResolvedManifest {
19
- /** `table.column` → column, for the exact per-query resolution. */
20
- qualified: Map<string, EncryptedColumn>
21
- /** `column` → column, the fallback for when the query's tables say nothing. */
22
- flat: Map<string, EncryptedColumn>
23
- }
24
-
25
- function snakeToCamel(name: string): string {
26
- return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
27
- }
28
-
29
- /**
30
- * Flatten the manifest into a table-qualified lookup plus a bare column-name
31
- * fallback, both indexed under snake_case and camelCase so the plugin works on
32
- * either side of `CamelCasePlugin` in the array.
33
- *
34
- * A bare name two tables disagree about is dropped rather than letting the last
35
- * one win: on a joined query it could carry either, and guessing wrong means
36
- * either handing back ciphertext or refusing a legitimate write. The qualified
37
- * map still resolves it whenever the query names its tables.
38
- */
39
- function buildManifest(manifest: ClassificationManifest): ResolvedManifest {
40
- const qualified = new Map<string, EncryptedColumn>()
41
- const flat = new Map<string, EncryptedColumn>()
42
- const ambiguous = new Set<string>()
43
-
44
- const assignFlat = (key: string, column: EncryptedColumn) => {
45
- const existing = flat.get(key)
46
- if (existing && existing.keyId !== column.keyId) {
47
- ambiguous.add(key)
48
- return
49
- }
50
- flat.set(key, column)
51
- }
52
-
53
- for (const [table, columns] of Object.entries(manifest.tables)) {
54
- for (const [name, classification] of Object.entries(columns)) {
55
- const form = classification.form
56
- if (form !== 'wrapped' && form !== 'sealed') continue
57
-
58
- const column: EncryptedColumn = {
59
- form,
60
- keyId: classification.keyId ?? DEFAULT_KEY_ID,
61
- }
62
- const camel = snakeToCamel(name)
63
- qualified.set(`${table}.${name}`, column)
64
- qualified.set(`${table}.${camel}`, column)
65
- assignFlat(name, column)
66
- assignFlat(camel, column)
67
- }
68
- }
69
-
70
- for (const key of ambiguous) flat.delete(key)
71
- return { qualified, flat }
72
- }
73
-
74
- function collectQueryTables(node: unknown, out: Set<string>): void {
75
- if (!node || typeof node !== 'object') return
76
-
77
- const op = node as {
78
- kind?: string
79
- table?: { identifier?: { name?: string } }
80
- }
81
- if (op.kind === 'TableNode') {
82
- const name = op.table?.identifier?.name
83
- if (typeof name === 'string' && name.length > 0) out.add(name)
84
- }
85
-
86
- for (const value of Object.values(node)) {
87
- if (Array.isArray(value)) {
88
- for (const item of value) collectQueryTables(item, out)
89
- } else {
90
- collectQueryTables(value, out)
91
- }
92
- }
93
- }
94
-
95
- function lookupColumn(
96
- manifest: ResolvedManifest,
97
- tables: readonly string[],
98
- column: string
99
- ): EncryptedColumn | undefined {
100
- let matched: EncryptedColumn | undefined
101
- for (const table of tables) {
102
- const found = manifest.qualified.get(`${table}.${column}`)
103
- if (!found) continue
104
- if (matched && matched.keyId !== found.keyId) {
105
- return manifest.flat.get(column)
106
- }
107
- matched = found
108
- }
109
- return matched ?? manifest.flat.get(column)
110
- }
111
-
112
- /**
113
- * The column names an insert or update writes, paired with the values going
114
- * into them.
115
- *
116
- * Only literal values are collected. A raw expression or a sub-select is
117
- * something this plugin cannot read and must not guess at, so it is skipped —
118
- * the guard's job is to catch a plaintext string a call site forgot to encrypt,
119
- * not to prove every write correct.
120
- */
121
- function collectWrittenValues(node: unknown): Array<[string, unknown]> {
122
- const written: Array<[string, unknown]> = []
123
-
124
- const visit = (current: unknown): void => {
125
- if (!current || typeof current !== 'object') return
126
- const op = current as {
127
- kind?: string
128
- column?: { column?: { name?: string } }
129
- value?: { kind?: string; value?: unknown }
130
- columns?: Array<{ column?: { name?: string } }>
131
- values?: unknown
132
- }
133
-
134
- if (op.kind === 'ColumnUpdateNode') {
135
- const name = op.column?.column?.name
136
- if (typeof name === 'string' && op.value?.kind === 'ValueNode') {
137
- written.push([name, op.value.value])
138
- }
139
- }
140
-
141
- if (op.kind === 'InsertQueryNode' && Array.isArray(op.columns)) {
142
- const names = op.columns.map((c) => c.column?.name)
143
- const rows = (op.values as { kind?: string; values?: unknown[] })?.values
144
- if (Array.isArray(rows)) {
145
- for (const row of rows) {
146
- const list = (row as { kind?: string; values?: unknown[] })?.values
147
- if (!Array.isArray(list)) continue
148
- list.forEach((value, index) => {
149
- const name = names[index]
150
- if (typeof name !== 'string') return
151
- const unwrapped =
152
- value && typeof value === 'object' && 'kind' in value
153
- ? (value as { kind?: string; value?: unknown }).kind ===
154
- 'ValueNode'
155
- ? (value as { value?: unknown }).value
156
- : undefined
157
- : value
158
- if (unwrapped !== undefined) written.push([name, unwrapped])
159
- })
160
- }
161
- }
162
- }
163
-
164
- for (const value of Object.values(current)) {
165
- if (Array.isArray(value)) {
166
- for (const item of value) visit(item)
167
- } else {
168
- visit(value)
169
- }
170
- }
171
- }
172
-
173
- visit(node)
174
- return written
175
- }
176
-
177
- export interface CreateClassificationPluginOptions {
178
- manifest: ClassificationManifest
179
- crypto: ClassificationCrypto
180
- }
181
-
182
- /**
183
- * Make a column's at-rest form a property of the column rather than of the
184
- * developer's memory.
185
- *
186
- * Reads are transparent: a `wrapped` column comes back decrypted, so
187
- * application code never sees an envelope. `hashed` and `plain` columns are
188
- * untouched, which is what keeps the rest of the schema queryable.
189
- *
190
- * Writes are *guarded* rather than transformed, and the reason is Kysely's
191
- * shape, not a preference: `transformQuery` is synchronous and WebCrypto is
192
- * not, so a plugin cannot encrypt on the way in. Encrypting at the call site
193
- * and letting a forgotten call slip through silently is the failure this
194
- * design exists to prevent, so the guard refuses any plaintext heading for a
195
- * `wrapped` or `sealed` column. Values are produced with
196
- * `ClassificationCrypto.encryptColumn`.
197
- *
198
- * A `sealed` column is never decrypted on the way out — the application holds
199
- * only the public half by definition, so returning it as stored is correct
200
- * rather than a gap.
201
- */
202
- export function createClassificationPlugin(
203
- options: CreateClassificationPluginOptions
204
- ): KyselyPlugin {
205
- const manifest = buildManifest(options.manifest)
206
- const queryTables = new WeakMap<object, readonly string[]>()
207
-
208
- return {
209
- transformQuery(args) {
210
- const tables = new Set<string>()
211
- collectQueryTables(args.node as RootOperationNode, tables)
212
- queryTables.set(args.queryId, [...tables])
213
-
214
- for (const [name, value] of collectWrittenValues(args.node)) {
215
- if (value === null || value === undefined) continue
216
- const column = lookupColumn(manifest, [...tables], name)
217
- if (!column) continue
218
- if (!isColumnEnvelope(value)) {
219
- throw new Error(
220
- `Refusing to write plaintext into "${name}", which is classified ${column.form}. ` +
221
- `Produce the value with ClassificationCrypto.encryptColumn() before writing it.`
222
- )
223
- }
224
- }
225
-
226
- return args.node
227
- },
228
-
229
- async transformResult(args) {
230
- const tables = queryTables.get(args.queryId) ?? []
231
- const rows: UnknownRow[] = []
232
-
233
- for (const row of args.result.rows as UnknownRow[]) {
234
- let next: UnknownRow | undefined
235
- for (const [name, value] of Object.entries(row)) {
236
- if (value === null || value === undefined) continue
237
- const column = lookupColumn(manifest, tables, name)
238
- if (!column || column.form !== 'wrapped') continue
239
- if (!isColumnEnvelope(value)) continue
240
- next ??= { ...row }
241
- next[name] = await options.crypto.decryptColumn(value)
242
- }
243
- rows.push(next ?? row)
244
- }
245
-
246
- return { ...args.result, rows }
247
- },
248
- }
249
- }
@@ -1,160 +0,0 @@
1
- import { describe, test } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import type { QueryResult, UnknownRow } from 'kysely'
4
- import { createCoercionPlugin, type CoercionMap } from './coercion-plugin.js'
5
-
6
- function selectFrom(...tables: string[]) {
7
- return {
8
- kind: 'SelectQueryNode',
9
- from: {
10
- kind: 'FromNode',
11
- froms: tables.map((table) => ({
12
- kind: 'TableNode',
13
- table: {
14
- kind: 'SchemableIdentifierNode',
15
- identifier: { kind: 'IdentifierNode', name: table },
16
- },
17
- })),
18
- },
19
- }
20
- }
21
-
22
- const runTransform = async (
23
- map: CoercionMap,
24
- rows: UnknownRow[],
25
- node?: unknown
26
- ): Promise<UnknownRow[]> => {
27
- const plugin = createCoercionPlugin({ map })
28
- const queryId = {} as { queryId: string }
29
- if (node !== undefined) {
30
- plugin.transformQuery({ node, queryId } as any)
31
- }
32
- const result = { rows } as QueryResult<UnknownRow>
33
- const out = await plugin.transformResult({ result, queryId } as any)
34
- return out.rows as UnknownRow[]
35
- }
36
-
37
- describe('createCoercionPlugin', () => {
38
- test('transformQuery passes the node through untouched', () => {
39
- const plugin = createCoercionPlugin({ map: {} })
40
- const node = selectFrom('users') as any
41
- assert.equal(
42
- plugin.transformQuery({ node, queryId: { queryId: 'x' } } as any),
43
- node
44
- )
45
- })
46
-
47
- test('coerces date columns from ISO strings to Date', async () => {
48
- const [row] = await runTransform({ users: { created_at: 'date' } }, [
49
- { created_at: '2026-06-26T00:00:00.000Z' },
50
- ])
51
- assert.ok(row!.created_at instanceof Date)
52
- assert.equal(
53
- (row!.created_at as Date).toISOString(),
54
- '2026-06-26T00:00:00.000Z'
55
- )
56
- })
57
-
58
- test('leaves unparseable date strings untouched', async () => {
59
- const [row] = await runTransform({ users: { created_at: 'date' } }, [
60
- { created_at: 'not-a-date' },
61
- ])
62
- assert.equal(row!.created_at, 'not-a-date')
63
- })
64
-
65
- test('coerces bool columns from 0/1 numbers and bigints', async () => {
66
- const rows = await runTransform(
67
- { users: { is_active: 'bool', has_pets: 'bool' } },
68
- [{ is_active: 1, has_pets: 0n }]
69
- )
70
- assert.equal(rows[0]!.is_active, true)
71
- assert.equal(rows[0]!.has_pets, false)
72
- })
73
-
74
- test('parses json columns and tolerates invalid json', async () => {
75
- const rows = await runTransform(
76
- { users: { meta: 'json', broken: 'json' } },
77
- [{ meta: '{"a":1}', broken: '{bad' }]
78
- )
79
- assert.deepEqual(rows[0]!.meta, { a: 1 })
80
- assert.equal(rows[0]!.broken, '{bad')
81
- })
82
-
83
- test('passes through null values and unmapped columns', async () => {
84
- const rows = await runTransform({ users: { created_at: 'date' } }, [
85
- { created_at: null, name: 'leave me' },
86
- ])
87
- assert.equal(rows[0]!.created_at, null)
88
- assert.equal(rows[0]!.name, 'leave me')
89
- })
90
-
91
- test('matches both snake_case and camelCase column names', async () => {
92
- const rows = await runTransform({ users: { created_at: 'date' } }, [
93
- { createdAt: '2026-06-26T00:00:00.000Z' },
94
- ])
95
- assert.ok(rows[0]!.createdAt instanceof Date)
96
- })
97
-
98
- test('leaves columns ambiguous across tables uncoerced', async () => {
99
- const rows = await runTransform(
100
- { users: { value: 'json' }, events: { value: 'bool' } },
101
- [{ value: '{"a":1}' }]
102
- )
103
- assert.equal(rows[0]!.value, '{"a":1}')
104
- })
105
-
106
- test('still coerces columns that agree across tables', async () => {
107
- const rows = await runTransform(
108
- { users: { active: 'bool' }, events: { active: 'bool' } },
109
- [{ active: 1 }]
110
- )
111
- assert.equal(rows[0]!.active, true)
112
- })
113
-
114
- test('uses table-qualified coercions when names collide', async () => {
115
- const map: CoercionMap = {
116
- users: { settings: 'json' },
117
- posts: { settings: 'bool' },
118
- }
119
- const fromUsers = await runTransform(
120
- map,
121
- [{ settings: '{"theme":"dark"}' }],
122
- selectFrom('users')
123
- )
124
- assert.deepEqual(fromUsers[0]!.settings, { theme: 'dark' })
125
-
126
- const fromPosts = await runTransform(
127
- map,
128
- [{ settings: 1 }],
129
- selectFrom('posts')
130
- )
131
- assert.equal(fromPosts[0]!.settings, true)
132
- })
133
-
134
- test('table-qualified lookup matches camelCase result columns too', async () => {
135
- const rows = await runTransform(
136
- { users: { created_at: 'date' }, jobs: { created_at: 'json' } },
137
- [{ createdAt: '2026-06-26T00:00:00.000Z' }],
138
- selectFrom('users')
139
- )
140
- assert.ok(rows[0]!.createdAt instanceof Date)
141
- })
142
-
143
- test('a join whose tables disagree leaves the column uncoerced', async () => {
144
- const rows = await runTransform(
145
- { users: { value: 'json' }, events: { value: 'bool' } },
146
- [{ value: '{"a":1}' }],
147
- selectFrom('users', 'events')
148
- )
149
- assert.equal(rows[0]!.value, '{"a":1}')
150
- })
151
-
152
- test('falls back to flat keys when the query names no known table', async () => {
153
- const rows = await runTransform(
154
- { users: { created_at: 'date' } },
155
- [{ createdAt: '2026-01-02T03:04:05.000Z' }],
156
- selectFrom('unrelated')
157
- )
158
- assert.ok(rows[0]!.createdAt instanceof Date)
159
- })
160
- })
@@ -1,187 +0,0 @@
1
- import type { KyselyPlugin, RootOperationNode, UnknownRow } from 'kysely'
2
-
3
- /**
4
- * The kinds a column can be coerced *to* at runtime. This is the value type of
5
- * the generated `CoercionMap`, so it is a public artifact contract — a kind may
6
- * only appear here if `fromDb` below actually does something with it.
7
- *
8
- * `uuid` is deliberately absent: it is an annotation kind (it selects the `Uuid`
9
- * TypeScript type in the generated schema) but not a coercion kind, and the
10
- * codegen filters it out of `coercion.gen.ts` for exactly that reason — a UUID
11
- * is a string in both Postgres and SQLite.
12
- */
13
- export type ColumnKind = 'date' | 'bool' | 'json'
14
-
15
- /**
16
- * Per-table column kind map. Keys are the table names queries are written
17
- * against; inner keys are snake_case column names.
18
- * Generated into `outDir/db/coercion.gen.ts` by `pikku db migrate` from the
19
- * explicit `kind` entries in `db/annotations.ts`.
20
- */
21
- export type CoercionMap = Record<string, Record<string, ColumnKind>>
22
-
23
- export interface CreateCoercionPluginOptions {
24
- map: CoercionMap
25
- }
26
-
27
- function fromDb(value: unknown, kind: ColumnKind): unknown {
28
- if (value == null) return value
29
- switch (kind) {
30
- case 'date':
31
- if (typeof value === 'string') {
32
- const d = new Date(value)
33
- return Number.isNaN(d.getTime()) ? value : d
34
- }
35
- return value
36
- case 'bool':
37
- if (typeof value === 'number') return value !== 0
38
- if (typeof value === 'bigint') return value !== 0n
39
- return value
40
- case 'json':
41
- if (typeof value !== 'string') return value
42
- try {
43
- return JSON.parse(value)
44
- } catch {
45
- return value
46
- }
47
- }
48
- }
49
-
50
- function snakeToCamel(name: string): string {
51
- return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
52
- }
53
-
54
- interface ResolvedMaps {
55
- /** `table.column` → kind, for the exact per-query resolution. */
56
- qualified: Record<string, ColumnKind>
57
- /** `column` → kind, the fallback for when the query's tables say nothing. */
58
- flat: Record<string, ColumnKind>
59
- }
60
-
61
- /**
62
- * Flatten the per-table map into a table-qualified lookup plus a bare
63
- * column-name fallback. Both are indexed by BOTH snake_case and camelCase so
64
- * they work regardless of CamelCasePlugin ordering in the plugin array.
65
- *
66
- * When two tables disagree on the kind for the same column name, the bare name
67
- * is ambiguous (a joined/aliased query could carry either) and is dropped from
68
- * the flat map rather than letting whichever table was processed last win. The
69
- * qualified map still resolves it whenever the query names its tables.
70
- */
71
- function buildMaps(map: CoercionMap): ResolvedMaps {
72
- const qualified: Record<string, ColumnKind> = {}
73
- const flat: Record<string, ColumnKind> = {}
74
- const ambiguous = new Set<string>()
75
- const assignFlat = (key: string, kind: ColumnKind) => {
76
- const existing = flat[key]
77
- if (existing !== undefined && existing !== kind) {
78
- ambiguous.add(key)
79
- return
80
- }
81
- flat[key] = kind
82
- }
83
- for (const [table, cols] of Object.entries(map)) {
84
- for (const [col, kind] of Object.entries(cols)) {
85
- const camel = snakeToCamel(col)
86
- qualified[`${table}.${col}`] = kind
87
- qualified[`${table}.${camel}`] = kind
88
- assignFlat(col, kind)
89
- assignFlat(camel, kind)
90
- }
91
- }
92
- for (const key of ambiguous) delete flat[key]
93
- return { qualified, flat }
94
- }
95
-
96
- /**
97
- * Collect every table named anywhere in the query's operation node, so a result
98
- * column can be resolved against the tables the query actually touched instead
99
- * of against every table in the schema.
100
- */
101
- function collectQueryTables(node: unknown, out: Set<string>): void {
102
- if (!node || typeof node !== 'object') return
103
-
104
- const op = node as {
105
- kind?: string
106
- table?: { identifier?: { name?: string } }
107
- }
108
- if (op.kind === 'TableNode') {
109
- const tableName = op.table?.identifier?.name
110
- if (typeof tableName === 'string' && tableName.length > 0)
111
- out.add(tableName)
112
- }
113
-
114
- for (const value of Object.values(node)) {
115
- if (Array.isArray(value)) {
116
- for (const item of value) collectQueryTables(item, out)
117
- } else {
118
- collectQueryTables(value, out)
119
- }
120
- }
121
- }
122
-
123
- /**
124
- * Resolve a result column's kind against the query's tables, falling back to
125
- * the bare column name. Tables that disagree with each other are as ambiguous
126
- * as two schema tables are, so they fall back too.
127
- */
128
- function lookupKind(
129
- maps: ResolvedMaps,
130
- tables: readonly string[],
131
- col: string
132
- ): ColumnKind | undefined {
133
- let matchedKind: ColumnKind | undefined
134
- for (const table of tables) {
135
- const kind = maps.qualified[`${table}.${col}`]
136
- if (!kind) continue
137
- if (matchedKind && matchedKind !== kind) return maps.flat[col]
138
- matchedKind = kind
139
- }
140
- return matchedKind ?? maps.flat[col]
141
- }
142
-
143
- /**
144
- * Convert values stored in a dialect's on-disk representation back into the
145
- * logical types app code expects (Date / boolean / parsed JSON).
146
- *
147
- * SQLite is what makes this necessary — it stores dates as TEXT, booleans as
148
- * INTEGER and JSON as TEXT — but the plugin is dialect-neutral: on Postgres the
149
- * driver already returns Date/boolean/object, and every branch of `fromDb`
150
- * passes a non-string, non-number value straight through. That is why it lives
151
- * in `@pikku/kysely` next to `SerializePlugin` rather than in a SQLite package:
152
- * the CLI installs it on its local Postgres database too.
153
- *
154
- * Write-side coercion (Date → ISO string, boolean → 0/1, object → JSON) is
155
- * handled in the SQLite database adapters (always-on), so this plugin is
156
- * read-only.
157
- *
158
- * Place AFTER CamelCasePlugin in the plugin array (or it handles both
159
- * orderings via the dual-keyed maps).
160
- */
161
- export function createCoercionPlugin(
162
- options: CreateCoercionPluginOptions
163
- ): KyselyPlugin {
164
- const maps = buildMaps(options.map)
165
- const queryTables = new WeakMap<object, readonly string[]>()
166
- return {
167
- transformQuery(args) {
168
- const tables = new Set<string>()
169
- collectQueryTables(args.node as RootOperationNode, tables)
170
- queryTables.set(args.queryId, [...tables])
171
- return args.node
172
- },
173
- async transformResult(args) {
174
- const tables = queryTables.get(args.queryId) ?? []
175
- const out: UnknownRow[] = []
176
- for (const row of args.result.rows as UnknownRow[]) {
177
- const next: UnknownRow = { ...row }
178
- for (const [col, val] of Object.entries(row)) {
179
- const kind = lookupKind(maps, tables, col)
180
- if (kind) next[col] = fromDb(val, kind)
181
- }
182
- out.push(next)
183
- }
184
- return { ...args.result, rows: out }
185
- },
186
- }
187
- }