@pikku/kysely 0.13.24 → 0.13.27

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 (78) hide show
  1. package/CHANGELOG.md +235 -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-tables.d.ts +45 -0
  9. package/dist/src/schema/analytics.schema.d.ts +17 -0
  10. package/dist/src/schema/analytics.schema.js +48 -0
  11. package/dist/src/schema/flag.schema.d.ts +16 -0
  12. package/dist/src/schema/flag.schema.js +62 -0
  13. package/dist/src/schema/index.d.ts +2 -0
  14. package/dist/src/schema/index.js +6 -0
  15. package/package.json +11 -6
  16. package/run-tests.sh +9 -1
  17. package/bin/pikku-kysely-pure.ts +0 -59
  18. package/dist/src/kysely-lock-vault.d.ts +0 -34
  19. package/dist/src/kysely-lock-vault.js +0 -77
  20. package/dist/src/schema/data-lock.schema.d.ts +0 -15
  21. package/dist/src/schema/data-lock.schema.js +0 -27
  22. package/dist/tsconfig.tsbuildinfo +0 -1
  23. package/src/classification-crypto.test.ts +0 -143
  24. package/src/classification-crypto.ts +0 -163
  25. package/src/classification-plugin.pglite.test.ts +0 -226
  26. package/src/classification-plugin.test.ts +0 -265
  27. package/src/classification-plugin.ts +0 -249
  28. package/src/coercion-plugin.test.ts +0 -160
  29. package/src/coercion-plugin.ts +0 -187
  30. package/src/create-audited-kysely.test.ts +0 -560
  31. package/src/create-audited-kysely.ts +0 -254
  32. package/src/index.ts +0 -81
  33. package/src/kysely-agent-run-scores.ts +0 -47
  34. package/src/kysely-agent-run-service.ts +0 -244
  35. package/src/kysely-agent-run-state-service.ts +0 -200
  36. package/src/kysely-agent-storage-service.ts +0 -632
  37. package/src/kysely-audit-service.test.ts +0 -352
  38. package/src/kysely-audit-service.ts +0 -237
  39. package/src/kysely-channel-store.ts +0 -107
  40. package/src/kysely-credential-service.ts +0 -286
  41. package/src/kysely-deployment-service.ts +0 -163
  42. package/src/kysely-eventhub-store.ts +0 -53
  43. package/src/kysely-json.ts +0 -5
  44. package/src/kysely-scope-service.test.ts +0 -418
  45. package/src/kysely-scope-service.ts +0 -425
  46. package/src/kysely-secret-service.ts +0 -242
  47. package/src/kysely-services.test.ts +0 -405
  48. package/src/kysely-session-store.ts +0 -57
  49. package/src/kysely-tables.test.ts +0 -110
  50. package/src/kysely-tables.ts +0 -434
  51. package/src/kysely-virtual-user-run-store.test.ts +0 -369
  52. package/src/kysely-virtual-user-run-store.ts +0 -235
  53. package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
  54. package/src/kysely-virtual-user-schedule-store.ts +0 -187
  55. package/src/kysely-webhook-service.test.ts +0 -153
  56. package/src/kysely-webhook-service.ts +0 -139
  57. package/src/kysely-workflow-mirror.test.ts +0 -259
  58. package/src/kysely-workflow-mirror.ts +0 -374
  59. package/src/kysely-workflow-run-service.ts +0 -305
  60. package/src/kysely-workflow-service.test.ts +0 -544
  61. package/src/kysely-workflow-service.ts +0 -820
  62. package/src/schema/agent.schema.ts +0 -162
  63. package/src/schema/audit.schema.ts +0 -57
  64. package/src/schema/channel.schema.ts +0 -38
  65. package/src/schema/credential.schema.ts +0 -58
  66. package/src/schema/deployment.schema.ts +0 -47
  67. package/src/schema/index.ts +0 -441
  68. package/src/schema/pikku-schema.types.ts +0 -169
  69. package/src/schema/schema.test.ts +0 -591
  70. package/src/schema/scope.schema.ts +0 -110
  71. package/src/schema/secret.schema.ts +0 -49
  72. package/src/schema/session.schema.ts +0 -20
  73. package/src/schema/virtual-user-schedule.schema.ts +0 -48
  74. package/src/schema/virtual-user.schema.ts +0 -106
  75. package/src/schema/webhook.schema.ts +0 -58
  76. package/src/schema/workflow.schema.ts +0 -150
  77. package/src/serialize-plugin.ts +0 -233
  78. package/tsconfig.json +0 -17
@@ -1,143 +0,0 @@
1
- import { describe, test } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { deriveKEK, generateKEKSalt } from '@pikku/core/crypto-utils'
4
- import {
5
- ClassificationCrypto,
6
- DEFAULT_KEY_ID,
7
- isColumnEnvelope,
8
- parseColumnEnvelope,
9
- type KEKResolver,
10
- } from './classification-crypto.js'
11
-
12
- const passphrase = 'a-passphrase-long-enough-to-be-real-key-material'
13
-
14
- /**
15
- * A resolver that derives a KEK per keyId and remembers it.
16
- *
17
- * Caching is the part under test as much as the derivation: `resolveKEK` is
18
- * called per operation, and a resolver that re-derived every time would pay
19
- * key stretching on every row.
20
- */
21
- const testResolver = (): KEKResolver => {
22
- const keks = new Map<string, Promise<CryptoKey>>()
23
- return async (keyId) => {
24
- let kek = keks.get(keyId)
25
- if (!kek) {
26
- kek = deriveKEK(passphrase, generateKEKSalt())
27
- keks.set(keyId, kek)
28
- }
29
- return { kek: await kek, keyVersion: 1 }
30
- }
31
- }
32
-
33
- const staticCrypto = async () =>
34
- new ClassificationCrypto({ resolveKEK: testResolver() })
35
-
36
- describe('column envelope', () => {
37
- test('round-trips a value through the default key', async () => {
38
- const crypto = await staticCrypto()
39
- const envelope = await crypto.encryptColumn(DEFAULT_KEY_ID, 'hunter2')
40
- assert.equal(await crypto.decryptColumn(envelope), 'hunter2')
41
- })
42
-
43
- test('the stored envelope names the key that protects it', async () => {
44
- const crypto = await staticCrypto()
45
- const envelope = await crypto.encryptColumn('credentials', 'shh')
46
- const parsed = parseColumnEnvelope(envelope)
47
- assert.equal(parsed?.keyId, 'credentials')
48
- assert.equal(parsed?.keyVersion, 1)
49
- })
50
-
51
- test('a value is not stored in the clear', async () => {
52
- const crypto = await staticCrypto()
53
- const envelope = await crypto.encryptColumn(DEFAULT_KEY_ID, 'hunter2')
54
- assert.ok(!envelope.includes('hunter2'))
55
- })
56
-
57
- test('every value gets its own DEK', async () => {
58
- const crypto = await staticCrypto()
59
- const a = parseColumnEnvelope(
60
- await crypto.encryptColumn(DEFAULT_KEY_ID, 'same')
61
- )
62
- const b = parseColumnEnvelope(
63
- await crypto.encryptColumn(DEFAULT_KEY_ID, 'same')
64
- )
65
- assert.notEqual(a?.wrappedDek, b?.wrappedDek)
66
- assert.notEqual(a?.ciphertext, b?.ciphertext)
67
- })
68
-
69
- test('round-trips non-string values', async () => {
70
- const crypto = await staticCrypto()
71
- const value = { card: '4242', meta: [1, 2, 3] }
72
- const envelope = await crypto.encryptColumn(DEFAULT_KEY_ID, value)
73
- assert.deepEqual(await crypto.decryptColumn(envelope), value)
74
- })
75
-
76
- test('plaintext is not mistaken for an envelope', () => {
77
- assert.equal(isColumnEnvelope('hunter2'), false)
78
- assert.equal(isColumnEnvelope(''), false)
79
- assert.equal(parseColumnEnvelope('hunter2'), null)
80
- })
81
-
82
- test('a keyId containing the separator survives the round trip', async () => {
83
- const crypto = await staticCrypto()
84
- const envelope = await crypto.encryptColumn('tenant.42', 'x')
85
- assert.equal(parseColumnEnvelope(envelope)?.keyId, 'tenant.42')
86
- assert.equal(await crypto.decryptColumn(envelope), 'x')
87
- })
88
- })
89
-
90
- describe('KEK scoping', () => {
91
- test('different keyIds derive different KEKs from one passphrase', async () => {
92
- const resolve = testResolver()
93
- const a = await resolve(DEFAULT_KEY_ID)
94
- const b = await resolve('credentials')
95
- assert.notEqual(a.kek, b.kek)
96
- })
97
-
98
- test('a value written under one keyId does not open under another', async () => {
99
- const crypto = await staticCrypto()
100
- const envelope = await crypto.encryptColumn('credentials', 'shh')
101
- const forged = envelope.replace(
102
- /^(pikku1\.)[^.]+/,
103
- `$1${Buffer.from('notes').toString('base64url')}`
104
- )
105
- await assert.rejects(() => crypto.decryptColumn(forged))
106
- })
107
-
108
- test('the same keyId resolves to the same KEK, so derivation is paid once', async () => {
109
- const resolve = testResolver()
110
- assert.equal(
111
- (await resolve(DEFAULT_KEY_ID)).kek,
112
- (await resolve(DEFAULT_KEY_ID)).kek
113
- )
114
- })
115
-
116
- test('rewrapping moves a value to a new key without touching the ciphertext', async () => {
117
- const crypto = await staticCrypto()
118
- const envelope = await crypto.encryptColumn(DEFAULT_KEY_ID, 'portable')
119
- const before = parseColumnEnvelope(envelope)
120
-
121
- const rewrapped = await crypto.rewrapColumn(envelope, 'credentials')
122
- const after = parseColumnEnvelope(rewrapped)
123
-
124
- assert.equal(after?.keyId, 'credentials')
125
- assert.equal(
126
- after?.ciphertext,
127
- before?.ciphertext,
128
- 'rewrap must re-wrap the DEK, not re-encrypt the value'
129
- )
130
- assert.notEqual(after?.wrappedDek, before?.wrappedDek)
131
- assert.equal(await crypto.decryptColumn(rewrapped), 'portable')
132
- })
133
-
134
- test('a resolver may hand back a KEK it derived any way it likes', async () => {
135
- const kek = await deriveKEK(passphrase, generateKEKSalt())
136
- const crypto = new ClassificationCrypto({
137
- resolveKEK: async () => ({ kek, keyVersion: 7 }),
138
- })
139
- const envelope = await crypto.encryptColumn('anything', 'v')
140
- assert.equal(parseColumnEnvelope(envelope)?.keyVersion, 7)
141
- assert.equal(await crypto.decryptColumn(envelope), 'v')
142
- })
143
- })
@@ -1,163 +0,0 @@
1
- import {
2
- envelopeDecrypt,
3
- envelopeEncrypt,
4
- envelopeRewrap,
5
- } from '@pikku/core/crypto-utils'
6
- import { DEFAULT_KEY_ID } from '@pikku/core/classification'
7
- import type { WrappedValue } from '@pikku/core/classification'
8
-
9
- /**
10
- * The key a column is protected by when its classification names none. A
11
- * deployment that never scopes its keys stores this id in every row and still
12
- * gets the scoping seam for free.
13
- *
14
- * Re-exported from core rather than declared again: the same constant decides
15
- * which record `initialize` mints and which one a stored envelope names, and
16
- * two copies of it would only ever be found to disagree by a row that could
17
- * not be opened.
18
- */
19
- export { DEFAULT_KEY_ID }
20
-
21
- const ENVELOPE_PREFIX = 'pikku1'
22
-
23
- /** The parts of a stored envelope, as they came off the column. */
24
- export interface ColumnEnvelope {
25
- keyId: string
26
- keyVersion: number
27
- wrappedDek: string
28
- ciphertext: string
29
- }
30
-
31
- /**
32
- * A KEK for one `keyId`, plus the version it was derived at.
33
- *
34
- * The version is the resolver's to report rather than the caller's to ask for:
35
- * on a write it stamps the row, and on a read the row already carries the
36
- * version the value was written under.
37
- */
38
- export interface ResolvedKEK {
39
- kek: CryptoKey
40
- keyVersion: number
41
- }
42
-
43
- /**
44
- * Maps a `keyId` — and, when reading, the version the row was written under —
45
- * to the KEK that opens it.
46
- *
47
- * This is the seam the whole design turns on. An unlock gate, a per-tenant
48
- * lookup or a KMS call all replace this one function without reshaping anything
49
- * that calls it.
50
- */
51
- export type KEKResolver = (
52
- keyId: string,
53
- keyVersion?: number
54
- ) => Promise<ResolvedKEK>
55
-
56
- const encodePart = (value: string): string =>
57
- Buffer.from(value, 'utf8').toString('base64url')
58
-
59
- const decodePart = (value: string): string =>
60
- Buffer.from(value, 'base64url').toString('utf8')
61
-
62
- const serializeEnvelope = (envelope: ColumnEnvelope): WrappedValue =>
63
- [
64
- ENVELOPE_PREFIX,
65
- encodePart(envelope.keyId),
66
- String(envelope.keyVersion),
67
- envelope.wrappedDek,
68
- envelope.ciphertext,
69
- ].join('.') as WrappedValue
70
-
71
- /**
72
- * Read a stored envelope, or null when the value is not one.
73
- *
74
- * Every part is base64url except the version, so `.` cannot occur inside a part
75
- * and a fixed split is unambiguous — a `keyId` may contain dots.
76
- */
77
- export const parseColumnEnvelope = (value: unknown): ColumnEnvelope | null => {
78
- if (typeof value !== 'string') return null
79
- const parts = value.split('.')
80
- if (parts.length !== 5 || parts[0] !== ENVELOPE_PREFIX) return null
81
-
82
- const [, encodedKeyId, rawVersion, wrappedDek, ciphertext] = parts
83
- const keyVersion = Number(rawVersion)
84
- if (!Number.isInteger(keyVersion) || !wrappedDek || !ciphertext) return null
85
-
86
- try {
87
- return {
88
- keyId: decodePart(encodedKeyId!),
89
- keyVersion,
90
- wrappedDek,
91
- ciphertext,
92
- }
93
- } catch {
94
- return null
95
- }
96
- }
97
-
98
- export const isColumnEnvelope = (value: unknown): boolean =>
99
- parseColumnEnvelope(value) !== null
100
-
101
- export interface ClassificationCryptoOptions {
102
- resolveKEK: KEKResolver
103
- }
104
-
105
- /**
106
- * Envelope encryption for a single classified column.
107
- *
108
- * Unlike `KyselySecretService`, which owns its table and can afford a column
109
- * per envelope part, a classified column is one column in someone else's table.
110
- * So the envelope is self-describing: the `keyId` and `keyVersion` travel in
111
- * the value, which is what lets a row record the key that protects it without
112
- * a schema change to every table that has a secret in it.
113
- */
114
- export class ClassificationCrypto {
115
- private readonly resolveKEK: KEKResolver
116
-
117
- constructor(options: ClassificationCryptoOptions) {
118
- this.resolveKEK = options.resolveKEK
119
- }
120
-
121
- async encryptColumn(keyId: string, value: unknown): Promise<WrappedValue> {
122
- const { kek, keyVersion } = await this.resolveKEK(keyId)
123
- const { ciphertext, wrappedDEK } = await envelopeEncrypt(kek, value)
124
- return serializeEnvelope({
125
- keyId,
126
- keyVersion,
127
- wrappedDek: wrappedDEK,
128
- ciphertext,
129
- })
130
- }
131
-
132
- async decryptColumn<T = unknown>(value: unknown): Promise<T> {
133
- const envelope = parseColumnEnvelope(value)
134
- if (!envelope) {
135
- throw new Error('Value is not a pikku column envelope')
136
- }
137
- const { kek } = await this.resolveKEK(envelope.keyId, envelope.keyVersion)
138
- return envelopeDecrypt<T>(kek, envelope.ciphertext, envelope.wrappedDek)
139
- }
140
-
141
- /**
142
- * Move a value to another key by re-wrapping its DEK. The ciphertext is
143
- * untouched, so the cost is a constant per row rather than proportional to
144
- * the value — the property that makes rescoping and rotation affordable.
145
- */
146
- async rewrapColumn(value: unknown, newKeyId: string): Promise<WrappedValue> {
147
- const envelope = parseColumnEnvelope(value)
148
- if (!envelope) {
149
- throw new Error('Value is not a pikku column envelope')
150
- }
151
- const { kek: oldKEK } = await this.resolveKEK(
152
- envelope.keyId,
153
- envelope.keyVersion
154
- )
155
- const { kek: newKEK, keyVersion } = await this.resolveKEK(newKeyId)
156
- return serializeEnvelope({
157
- keyId: newKeyId,
158
- keyVersion,
159
- wrappedDek: await envelopeRewrap(oldKEK, newKEK, envelope.wrappedDek),
160
- ciphertext: envelope.ciphertext,
161
- })
162
- }
163
- }
@@ -1,226 +0,0 @@
1
- import { describe, test, before, after } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import {
4
- CamelCasePlugin,
5
- Kysely,
6
- PostgresAdapter,
7
- PostgresIntrospector,
8
- PostgresQueryCompiler,
9
- sql,
10
- type CompiledQuery,
11
- type DatabaseConnection,
12
- type Driver,
13
- type QueryResult,
14
- } from 'kysely'
15
- import { PGlite } from '@electric-sql/pglite'
16
- import type { ClassificationManifest } from '@pikku/core/classification'
17
- import { deriveKEK, generateKEKSalt } from '@pikku/core/crypto-utils'
18
- import {
19
- ClassificationCrypto,
20
- type KEKResolver,
21
- } from './classification-crypto.js'
22
- import { createClassificationPlugin } from './classification-plugin.js'
23
-
24
- interface AccountsDB {
25
- accounts: {
26
- id: string
27
- email: string
28
- ssn: string
29
- recovery: string
30
- }
31
- }
32
-
33
- const manifest: ClassificationManifest = {
34
- version: 1,
35
- tables: {
36
- accounts: {
37
- id: { classification: 'public', anonymize_strategy: null },
38
- email: { classification: 'pii', anonymize_strategy: 'fake:email' },
39
- ssn: {
40
- classification: 'secret',
41
- anonymize_strategy: null,
42
- form: 'wrapped',
43
- },
44
- recovery: {
45
- classification: 'secret',
46
- anonymize_strategy: null,
47
- form: 'wrapped',
48
- keyId: 'recovery-codes',
49
- },
50
- },
51
- },
52
- }
53
-
54
- class PGliteConnection implements DatabaseConnection {
55
- constructor(private readonly db: PGlite) {}
56
-
57
- async executeQuery<R>(query: CompiledQuery): Promise<QueryResult<R>> {
58
- const result = await this.db.query<R>(query.sql, [...query.parameters])
59
- return {
60
- rows: result.rows,
61
- numAffectedRows:
62
- result.affectedRows !== undefined
63
- ? BigInt(result.affectedRows)
64
- : undefined,
65
- }
66
- }
67
-
68
- async *streamQuery<R>(
69
- query: CompiledQuery
70
- ): AsyncIterableIterator<QueryResult<R>> {
71
- yield await this.executeQuery<R>(query)
72
- }
73
- }
74
-
75
- class PGliteDriver implements Driver {
76
- private readonly connection: PGliteConnection
77
- constructor(private readonly db: PGlite) {
78
- this.connection = new PGliteConnection(db)
79
- }
80
- async init(): Promise<void> {}
81
- async acquireConnection(): Promise<DatabaseConnection> {
82
- return this.connection
83
- }
84
- async beginTransaction(): Promise<void> {
85
- await this.db.exec('BEGIN')
86
- }
87
- async commitTransaction(): Promise<void> {
88
- await this.db.exec('COMMIT')
89
- }
90
- async rollbackTransaction(): Promise<void> {
91
- await this.db.exec('ROLLBACK')
92
- }
93
- async releaseConnection(): Promise<void> {}
94
- async destroy(): Promise<void> {}
95
- }
96
-
97
- const PASSPHRASE = 'a-passphrase-long-enough-to-be-real-key-material'
98
-
99
- let pglite: PGlite
100
- let db: Kysely<AccountsDB>
101
- let crypto: ClassificationCrypto
102
-
103
- before(async () => {
104
- const keks = new Map<string, Promise<CryptoKey>>()
105
- const resolveKEK: KEKResolver = async (keyId) => {
106
- let kek = keks.get(keyId)
107
- if (!kek) {
108
- kek = deriveKEK(PASSPHRASE, generateKEKSalt())
109
- keks.set(keyId, kek)
110
- }
111
- return { kek: await kek, keyVersion: 1 }
112
- }
113
- crypto = new ClassificationCrypto({ resolveKEK })
114
-
115
- pglite = new PGlite()
116
- db = new Kysely<AccountsDB>({
117
- dialect: {
118
- createAdapter: () => new PostgresAdapter(),
119
- createDriver: () => new PGliteDriver(pglite),
120
- createIntrospector: (i) => new PostgresIntrospector(i),
121
- createQueryCompiler: () => new PostgresQueryCompiler(),
122
- },
123
- plugins: [
124
- new CamelCasePlugin(),
125
- createClassificationPlugin({
126
- manifest,
127
- crypto,
128
- }),
129
- ],
130
- })
131
-
132
- await pglite.exec(`
133
- CREATE TABLE accounts (
134
- id TEXT PRIMARY KEY,
135
- email TEXT NOT NULL,
136
- ssn TEXT,
137
- recovery TEXT
138
- )
139
- `)
140
- })
141
-
142
- after(async () => {
143
- await pglite.close()
144
- })
145
-
146
- describe('against a real postgres', () => {
147
- test('a wrapped column round-trips through the database', async () => {
148
- await db
149
- .insertInto('accounts')
150
- .values({
151
- id: 'a1',
152
- email: 'a@b.c',
153
- ssn: (await crypto.encryptColumn('default', '123-45-6789')) as never,
154
- recovery: (await crypto.encryptColumn(
155
- 'recovery-codes',
156
- 'zzz-999'
157
- )) as never,
158
- })
159
- .execute()
160
-
161
- const row = await db
162
- .selectFrom('accounts')
163
- .selectAll()
164
- .where('id', '=', 'a1')
165
- .executeTakeFirstOrThrow()
166
-
167
- assert.equal(row.ssn, '123-45-6789')
168
- assert.equal(row.recovery, 'zzz-999')
169
- assert.equal(row.email, 'a@b.c')
170
- })
171
-
172
- test('what is actually on disk is ciphertext', async () => {
173
- const raw = await pglite.query<{ ssn: string }>(
174
- `SELECT ssn FROM accounts WHERE id = 'a1'`
175
- )
176
- const stored = raw.rows[0]!.ssn
177
- assert.ok(stored.startsWith('pikku1.'))
178
- assert.ok(!stored.includes('123-45-6789'))
179
- })
180
-
181
- test('a pii column is still queryable, which is the point', async () => {
182
- const row = await db
183
- .selectFrom('accounts')
184
- .select('id')
185
- .where('email', '=', 'a@b.c')
186
- .executeTakeFirst()
187
- assert.equal(row?.id, 'a1')
188
- })
189
-
190
- test('a forgotten encryption never reaches the database', async () => {
191
- await assert.rejects(
192
- () =>
193
- db
194
- .insertInto('accounts')
195
- .values({ id: 'a2', email: 'x@y.z', ssn: 'raw-ssn' as never })
196
- .execute(),
197
- /ssn/
198
- )
199
- const found = await db
200
- .selectFrom('accounts')
201
- .select('id')
202
- .where('id', '=', 'a2')
203
- .executeTakeFirst()
204
- assert.equal(found, undefined)
205
- })
206
-
207
- test('a rewrapped value still reads back through the plugin', async () => {
208
- const raw = await pglite.query<{ ssn: string }>(
209
- `SELECT ssn FROM accounts WHERE id = 'a1'`
210
- )
211
- const rewrapped = await crypto.rewrapColumn(
212
- raw.rows[0]!.ssn,
213
- 'recovery-codes'
214
- )
215
- await sql`UPDATE accounts SET ssn = ${rewrapped} WHERE id = 'a1'`.execute(
216
- db
217
- )
218
-
219
- const row = await db
220
- .selectFrom('accounts')
221
- .select('ssn')
222
- .where('id', '=', 'a1')
223
- .executeTakeFirstOrThrow()
224
- assert.equal(row.ssn, '123-45-6789')
225
- })
226
- })