@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,352 +0,0 @@
1
- import { describe, test, beforeEach } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import Database from 'better-sqlite3'
4
- import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
5
- import type { AuditEvent } from '@pikku/core/services'
6
-
7
- import { KyselyAuditService } from './kysely-audit-service.js'
8
-
9
- /**
10
- * The `audit` table as the docs specify it — all TEXT on every engine, so the
11
- * service is exercised against the same shape a project would migrate to.
12
- */
13
- const CREATE_AUDIT_TABLE = `
14
- CREATE TABLE audit (
15
- audit_id TEXT NOT NULL PRIMARY KEY,
16
- occurred_at TEXT NOT NULL,
17
- type TEXT NOT NULL,
18
- source TEXT NOT NULL DEFAULT 'auto',
19
- outcome TEXT,
20
- function_id TEXT,
21
- wire_type TEXT,
22
- trace_id TEXT,
23
- transaction_id TEXT,
24
- query_id TEXT,
25
- user_id TEXT,
26
- org_id TEXT,
27
- pikku_user_id TEXT,
28
- tables TEXT,
29
- changed_cols TEXT,
30
- event TEXT,
31
- old TEXT,
32
- data TEXT
33
- )
34
- `
35
-
36
- let db: Kysely<any>
37
- let service: KyselyAuditService
38
-
39
- const event = (
40
- overrides: Partial<AuditEvent> & Pick<AuditEvent, 'type' | 'occurredAt'>
41
- ): AuditEvent => ({
42
- source: 'explicit',
43
- ...overrides,
44
- })
45
-
46
- beforeEach(async () => {
47
- db = new Kysely<any>({
48
- dialect: new SqliteDialect({ database: new Database(':memory:') }),
49
- })
50
- await sql.raw(CREATE_AUDIT_TABLE).execute(db)
51
- service = new KyselyAuditService(db)
52
- })
53
-
54
- const seed = () =>
55
- service.write([
56
- event({
57
- eventId: 'e1',
58
- type: 'invoice.update',
59
- occurredAt: '2026-01-01T00:00:00.000Z',
60
- userIdentity: { userId: 'alice', orgId: 'org-a' },
61
- metadata: { entity: 'invoice', entityId: 'inv-1' },
62
- }),
63
- event({
64
- eventId: 'e2',
65
- type: 'invoice.delete',
66
- occurredAt: '2026-01-02T00:00:00.000Z',
67
- userIdentity: { userId: 'bob', orgId: 'org-a' },
68
- }),
69
- event({
70
- eventId: 'e3',
71
- type: 'invoice.update',
72
- occurredAt: '2026-01-03T00:00:00.000Z',
73
- userIdentity: { userId: 'alice', orgId: 'org-b' },
74
- }),
75
- ])
76
-
77
- describe('KyselyAuditService.query', () => {
78
- test('returns the trail newest first', async () => {
79
- await seed()
80
- const { events, nextCursor } = await service.query()
81
- assert.deepEqual(
82
- events.map((e) => e.eventId),
83
- ['e3', 'e2', 'e1']
84
- )
85
- assert.equal(nextCursor, null, 'a complete page has no next cursor')
86
- })
87
-
88
- test('round-trips the event, including parsed metadata', async () => {
89
- await seed()
90
- const { events } = await service.query({ types: ['invoice.update'] })
91
- const first = events.find((e) => e.eventId === 'e1')!
92
- assert.equal(first.type, 'invoice.update')
93
- assert.equal(first.source, 'explicit')
94
- assert.deepEqual(first.userIdentity, {
95
- userId: 'alice',
96
- orgId: 'org-a',
97
- pikkuUserId: undefined,
98
- })
99
- assert.deepEqual(first.metadata, { entity: 'invoice', entityId: 'inv-1' })
100
- })
101
-
102
- test('keeps the wire identity of a caller who never signed in', async () => {
103
- await service.write([
104
- event({
105
- eventId: 'e4',
106
- type: 'invoice.view',
107
- occurredAt: '2026-01-04T00:00:00.000Z',
108
- userIdentity: { pikkuUserId: 'pk_9' },
109
- }),
110
- ])
111
- const { events } = await service.query({ types: ['invoice.view'] })
112
- assert.deepEqual(events[0]!.userIdentity, {
113
- userId: undefined,
114
- orgId: undefined,
115
- pikkuUserId: 'pk_9',
116
- })
117
- })
118
-
119
- test('filters by user', async () => {
120
- await seed()
121
- const { events } = await service.query({ userIds: ['alice'] })
122
- assert.deepEqual(
123
- events.map((e) => e.eventId),
124
- ['e3', 'e1']
125
- )
126
- })
127
-
128
- test('filters by type', async () => {
129
- await seed()
130
- const { events } = await service.query({ types: ['invoice.delete'] })
131
- assert.deepEqual(
132
- events.map((e) => e.eventId),
133
- ['e2']
134
- )
135
- })
136
-
137
- test('combines filters as a conjunction', async () => {
138
- await seed()
139
- const { events } = await service.query({
140
- userIds: ['alice'],
141
- types: ['invoice.update'],
142
- orgId: 'org-b',
143
- })
144
- assert.deepEqual(
145
- events.map((e) => e.eventId),
146
- ['e3']
147
- )
148
- })
149
-
150
- test('an empty filter array matches nothing rather than everything', async () => {
151
- await seed()
152
- const byUser = await service.query({ userIds: [] })
153
- assert.deepEqual(byUser.events, [])
154
- const byType = await service.query({ types: [] })
155
- assert.deepEqual(byType.events, [])
156
- })
157
-
158
- test('bounds by occurredAt, `from` inclusive and `to` exclusive', async () => {
159
- await seed()
160
- const { events } = await service.query({
161
- from: '2026-01-02T00:00:00.000Z',
162
- to: '2026-01-03T00:00:00.000Z',
163
- })
164
- assert.deepEqual(
165
- events.map((e) => e.eventId),
166
- ['e2']
167
- )
168
- })
169
-
170
- test('pages, reporting the next offset only while more remain', async () => {
171
- await seed()
172
- const first = await service.query({ limit: 2 })
173
- assert.deepEqual(
174
- first.events.map((e) => e.eventId),
175
- ['e3', 'e2']
176
- )
177
- assert.equal(first.nextCursor, 2)
178
-
179
- const second = await service.query({ limit: 2, offset: first.nextCursor! })
180
- assert.deepEqual(
181
- second.events.map((e) => e.eventId),
182
- ['e1']
183
- )
184
- assert.equal(second.nextCursor, null)
185
- })
186
-
187
- // Without the audit_id tiebreak the engine may order a same-timestamp batch
188
- // differently per query, which drops or repeats rows across a page boundary.
189
- test('orders totally when timestamps tie', async () => {
190
- const sameTime = '2026-02-01T00:00:00.000Z'
191
- await service.write(
192
- ['a', 'b', 'c', 'd'].map((id) =>
193
- event({ eventId: id, type: 't', occurredAt: sameTime })
194
- )
195
- )
196
- const first = await service.query({ limit: 2 })
197
- const second = await service.query({ limit: 2, offset: 2 })
198
- const seen = [...first.events, ...second.events].map((e) => e.eventId)
199
- assert.deepEqual(seen, ['d', 'c', 'b', 'a'])
200
- assert.equal(new Set(seen).size, 4, 'no row may appear on two pages')
201
- })
202
-
203
- test('caps an oversized limit', async () => {
204
- await service.write(
205
- Array.from({ length: 5 }, (_, i) =>
206
- event({
207
- eventId: `e${i}`,
208
- type: 't',
209
- occurredAt: `2026-03-0${i + 1}T00:00:00.000Z`,
210
- })
211
- )
212
- )
213
- const { events } = await service.query({ limit: 10_000 })
214
- assert.equal(events.length, 5, 'the cap must not lose rows below it')
215
- })
216
-
217
- test('a malformed data column surfaces rather than dropping the event', async () => {
218
- await sql
219
- .raw(
220
- `INSERT INTO audit (audit_id, occurred_at, type, source, data)
221
- VALUES ('bad', '2026-04-01T00:00:00.000Z', 'legacy.write', 'auto', 'not json')`
222
- )
223
- .execute(db)
224
- const { events } = await service.query()
225
- assert.equal(events.length, 1)
226
- assert.equal(events[0]!.metadata as unknown, 'not json')
227
- })
228
- })
229
-
230
- /**
231
- * The two connections a project actually hands this service. `CamelCasePlugin`
232
- * is on almost every pikku Kysely instance, and it renames result columns on
233
- * the way out — `audit_id` arrives as `auditId` — so a service that only reads
234
- * the physical names returns a page of undefined-everything against the setup
235
- * most projects have.
236
- */
237
- describe('KyselyAuditService against a CamelCasePlugin connection', () => {
238
- let camelDb: Kysely<any>
239
- let camelService: KyselyAuditService
240
-
241
- beforeEach(async () => {
242
- camelDb = new Kysely<any>({
243
- dialect: new SqliteDialect({ database: new Database(':memory:') }),
244
- plugins: [new CamelCasePlugin()],
245
- })
246
- await sql.raw(CREATE_AUDIT_TABLE).execute(camelDb)
247
- camelService = new KyselyAuditService(camelDb)
248
- })
249
-
250
- test('reads back everything it wrote', async () => {
251
- await camelService.write([
252
- event({
253
- eventId: 'e1',
254
- type: 'invoice.update',
255
- occurredAt: '2026-01-01T00:00:00.000Z',
256
- outcome: 'success',
257
- functionId: 'cancelInvoice',
258
- wireType: 'http',
259
- traceId: 'trace-1',
260
- userIdentity: { userId: 'alice', orgId: 'org-a' },
261
- metadata: { entity: 'invoice' },
262
- }),
263
- ])
264
- const { events } = await camelService.query()
265
- assert.equal(events.length, 1)
266
- assert.deepEqual(events[0], {
267
- eventId: 'e1',
268
- occurredAt: '2026-01-01T00:00:00.000Z',
269
- type: 'invoice.update',
270
- source: 'explicit',
271
- outcome: 'success',
272
- functionId: 'cancelInvoice',
273
- wireType: 'http',
274
- traceId: 'trace-1',
275
- transactionId: undefined,
276
- queryId: undefined,
277
- userIdentity: {
278
- userId: 'alice',
279
- orgId: 'org-a',
280
- pikkuUserId: undefined,
281
- },
282
- metadata: { entity: 'invoice' },
283
- })
284
- })
285
-
286
- test('still filters and facets by user', async () => {
287
- await camelService.write([
288
- event({
289
- eventId: 'e1',
290
- type: 'a',
291
- occurredAt: '2026-01-01T00:00:00.000Z',
292
- userIdentity: { userId: 'alice' },
293
- }),
294
- event({
295
- eventId: 'e2',
296
- type: 'b',
297
- occurredAt: '2026-01-02T00:00:00.000Z',
298
- userIdentity: { userId: 'bob' },
299
- }),
300
- ])
301
- const { events } = await camelService.query({ userIds: ['bob'] })
302
- assert.deepEqual(
303
- events.map((e) => e.eventId),
304
- ['e2']
305
- )
306
- const facets = await camelService.facets()
307
- assert.deepEqual(facets.userIds, ['alice', 'bob'])
308
- })
309
- })
310
-
311
- describe('KyselyAuditService.init', () => {
312
- test('refuses to boot against a database that has none', async () => {
313
- const fresh = new Kysely<any>({
314
- dialect: new SqliteDialect({ database: new Database(':memory:') }),
315
- plugins: [new CamelCasePlugin()],
316
- })
317
- const freshService = new KyselyAuditService(fresh)
318
- await assert.rejects(freshService.init(), /pikku db generate/)
319
- })
320
-
321
- // Two boots against one database, and the second must not wipe the first.
322
- test('is idempotent and keeps what is already there', async () => {
323
- await service.write([
324
- event({ eventId: 'e1', type: 't', occurredAt: '2026-01-01' }),
325
- ])
326
- await service.init()
327
- await service.init()
328
- const { events } = await service.query()
329
- assert.deepEqual(
330
- events.map((e) => e.eventId),
331
- ['e1']
332
- )
333
- })
334
- })
335
-
336
- describe('KyselyAuditService.facets', () => {
337
- test('lists the distinct users and types across the whole trail', async () => {
338
- await seed()
339
- const facets = await service.facets()
340
- assert.deepEqual(facets.userIds, ['alice', 'bob'])
341
- assert.deepEqual(facets.types, ['invoice.delete', 'invoice.update'])
342
- })
343
-
344
- test('omits userless events from the user list', async () => {
345
- await service.write([
346
- event({ eventId: 'cron', type: 'cleanup.ran', occurredAt: '2026-05-01' }),
347
- ])
348
- const facets = await service.facets()
349
- assert.deepEqual(facets.userIds, [])
350
- assert.deepEqual(facets.types, ['cleanup.ran'])
351
- })
352
- })
@@ -1,237 +0,0 @@
1
- import type { AuditService } from '@pikku/core/services'
2
- import type { AuditEvent, AuditEventBatch } from '@pikku/core/services'
3
- import type {
4
- AuditFacets,
5
- AuditQuery,
6
- AuditQueryResult,
7
- } from '@pikku/core/services'
8
- import type { Kysely } from 'kysely'
9
- import { requirePikkuSchema } from './schema/index.js'
10
- import { auditSchema } from './schema/audit.schema.js'
11
-
12
- const jsonOrNull = (v: unknown): string | null =>
13
- v != null ? JSON.stringify(v) : null
14
-
15
- const parseJson = (v: unknown): any => {
16
- if (typeof v !== 'string') return v ?? undefined
17
- try {
18
- return JSON.parse(v)
19
- } catch {
20
- // A row written by something other than this service, or truncated in
21
- // transit. Surfacing the raw text beats dropping the event: the row is
22
- // still evidence that something happened, and swallowing it would make the
23
- // trail quietly incomplete.
24
- return v
25
- }
26
- }
27
-
28
- /**
29
- * A column by its physical name, whatever the connection renamed it to.
30
- *
31
- * `CamelCasePlugin` is on most pikku Kysely instances and rewrites result keys
32
- * on the way out, so the same row arrives keyed `audit_id` or `auditId`
33
- * depending on a plugin this service is never told about. Reading both is
34
- * cheaper than demanding a particular connection — and quieter to get wrong,
35
- * since the mismatch does not throw, it just returns a page of undefined.
36
- */
37
- const column = (row: Record<string, any>, name: string): any => {
38
- const value = row[name]
39
- if (value !== undefined) return value
40
- return row[name.replace(/_(\w)/g, (_, char: string) => char.toUpperCase())]
41
- }
42
-
43
- /** Rows are TEXT on every engine, so the shape is whatever the driver returned. */
44
- const rowToEvent = (row: Record<string, any>): AuditEvent => {
45
- const userId = column(row, 'user_id')
46
- const orgId = column(row, 'org_id')
47
- const pikkuUserId = column(row, 'pikku_user_id')
48
- return {
49
- eventId: column(row, 'audit_id') ?? undefined,
50
- occurredAt: column(row, 'occurred_at'),
51
- type: row.type,
52
- source: row.source ?? 'auto',
53
- outcome: row.outcome ?? undefined,
54
- functionId: column(row, 'function_id') ?? undefined,
55
- wireType: column(row, 'wire_type') ?? undefined,
56
- traceId: column(row, 'trace_id') ?? undefined,
57
- transactionId: column(row, 'transaction_id') ?? undefined,
58
- queryId: column(row, 'query_id') ?? undefined,
59
- userIdentity:
60
- userId || orgId || pikkuUserId
61
- ? {
62
- userId: userId ?? undefined,
63
- orgId: orgId ?? undefined,
64
- pikkuUserId: pikkuUserId ?? undefined,
65
- }
66
- : undefined,
67
- metadata: parseJson(row.data),
68
- }
69
- }
70
-
71
- /** The most a single page may return, whatever the caller asked for. */
72
- const MAX_LIMIT = 200
73
- const DEFAULT_LIMIT = 50
74
-
75
- // No global `crypto` is guaranteed across every runtime, and audit_id is the
76
- // PK — any collision is dropped by ON CONFLICT DO NOTHING.
77
- const fallbackId = (): string =>
78
- `evt_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
79
-
80
- /**
81
- * Durable {@link AuditService} that persists AuditEvents to an `audit` table
82
- * via Kysely — the companion sink to {@link createAuditedKysely}.
83
- *
84
- * The column mapping matches a platform audit-queue consumer, so a locally-run
85
- * project and a deployed stage write identical rows (the read side relies only
86
- * on `type`, `occurred_at`, `user_id`, and the `data` JSON that holds
87
- * `metadata`). The `audit` table is an optional per-project migration (not in
88
- * the generated schema), so rows are inserted untyped; all columns are TEXT on
89
- * every engine and ON CONFLICT DO NOTHING keeps writes idempotent on retries.
90
- */
91
- export class KyselyAuditService implements AuditService {
92
- private initialized = false
93
-
94
- constructor(private db: Kysely<any>) {}
95
-
96
- /**
97
- * Creates the `audit` table if the database has none.
98
- *
99
- * Optional — a project that migrates the table itself can skip it, and the
100
- * declaration is written to match the documented shape either way. Calling it
101
- * on a database that already has the table is a no-op, so two instances
102
- * booting cold do not race each other into a half-applied schema.
103
- */
104
- public async init(): Promise<void> {
105
- if (this.initialized) return
106
- await requirePikkuSchema(this.db, auditSchema)
107
- this.initialized = true
108
- }
109
-
110
- async audit(event: AuditEvent): Promise<void> {
111
- await this.write([event])
112
- }
113
-
114
- async write(batch: AuditEventBatch): Promise<void> {
115
- if (!batch.length) return
116
- const rows = batch.map((e) => {
117
- const metadata = e.metadata as
118
- { tables?: unknown; changedColumns?: unknown } | undefined
119
- return {
120
- audit_id: e.eventId ?? e.queryId ?? fallbackId(),
121
- occurred_at: e.occurredAt ?? new Date().toISOString(),
122
- type: e.type ?? 'unknown',
123
- source: e.source ?? 'auto',
124
- outcome: e.outcome ?? null,
125
- function_id: e.functionId ?? null,
126
- wire_type: e.wireType ?? null,
127
- trace_id: e.traceId ?? null,
128
- transaction_id: e.transactionId ?? null,
129
- query_id: e.queryId ?? null,
130
- user_id: e.userIdentity?.userId ?? null,
131
- org_id: e.userIdentity?.orgId ?? null,
132
- pikku_user_id: e.userIdentity?.pikkuUserId ?? null,
133
- tables: jsonOrNull(metadata?.tables),
134
- changed_cols: jsonOrNull(metadata?.changedColumns),
135
- event: e.eventId ?? null,
136
- old: null,
137
- data: jsonOrNull(e.metadata),
138
- }
139
- })
140
- await (this.db as any)
141
- .insertInto('audit')
142
- .values(rows)
143
- .onConflict((oc: any) => oc.doNothing())
144
- .execute()
145
- }
146
-
147
- /**
148
- * A page of the trail, newest first.
149
- *
150
- * Offset paging, matching the rest of the console. The trail is append-only
151
- * and ordered by `occurred_at DESC`, so events written *while* a reader is
152
- * paging shift the window and can repeat a row across pages. That is visible
153
- * but harmless for browsing; anything that must not miss a row should page by
154
- * a bounded `from`/`to` window instead of scrolling.
155
- *
156
- * Ties on `occurred_at` are broken by `audit_id` so the order is total —
157
- * without it a batch flushed in one call shares a timestamp and the engine is
158
- * free to order it differently per query, which drops or repeats rows at a
159
- * page boundary.
160
- */
161
- async query(query: AuditQuery = {}): Promise<AuditQueryResult> {
162
- const limit = Math.min(Math.max(query.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT)
163
- const offset = Math.max(query.offset ?? 0, 0)
164
-
165
- let builder = (this.db as any).selectFrom('audit').selectAll()
166
- builder = this.applyFilters(builder, query)
167
-
168
- const rows = await builder
169
- .orderBy('occurred_at', 'desc')
170
- .orderBy('audit_id', 'desc')
171
- // One extra row is the cheapest honest way to know whether another page
172
- // exists — a COUNT would be a second scan of the same filter.
173
- .limit(limit + 1)
174
- .offset(offset)
175
- .execute()
176
-
177
- const hasMore = rows.length > limit
178
- return {
179
- events: rows.slice(0, limit).map(rowToEvent),
180
- nextCursor: hasMore ? offset + limit : null,
181
- }
182
- }
183
-
184
- async facets(): Promise<AuditFacets> {
185
- const [users, types] = await Promise.all([
186
- (this.db as any)
187
- .selectFrom('audit')
188
- .select('user_id')
189
- .distinct()
190
- .where('user_id', 'is not', null)
191
- .orderBy('user_id', 'asc')
192
- .execute(),
193
- (this.db as any)
194
- .selectFrom('audit')
195
- .select('type')
196
- .distinct()
197
- .orderBy('type', 'asc')
198
- .execute(),
199
- ])
200
- return {
201
- userIds: users.map((r: any) => column(r, 'user_id')),
202
- types: types.map((r: any) => r.type).filter(Boolean),
203
- }
204
- }
205
-
206
- /**
207
- * An empty array means "match nothing" and is applied as such. Treating it as
208
- * "no filter" would turn a reader's deselect-everything into a full-trail
209
- * read, which is the opposite of what they asked for.
210
- */
211
- private applyFilters(builder: any, query: AuditQuery): any {
212
- // `in ()` is a syntax error on Postgres and SQLite alike, so an empty
213
- // selection is expressed as a predicate that cannot hold.
214
- const matchNothing = (b: any) => b.where('audit_id', 'is', null)
215
-
216
- if (query.userIds) {
217
- builder = query.userIds.length
218
- ? builder.where('user_id', 'in', query.userIds)
219
- : matchNothing(builder)
220
- }
221
- if (query.types) {
222
- builder = query.types.length
223
- ? builder.where('type', 'in', query.types)
224
- : matchNothing(builder)
225
- }
226
- if (query.orgId) {
227
- builder = builder.where('org_id', '=', query.orgId)
228
- }
229
- if (query.from) {
230
- builder = builder.where('occurred_at', '>=', query.from)
231
- }
232
- if (query.to) {
233
- builder = builder.where('occurred_at', '<', query.to)
234
- }
235
- return builder
236
- }
237
- }
@@ -1,107 +0,0 @@
1
- import { ChannelStore } from '@pikku/core/channel'
2
- import type { Channel } from '@pikku/core/channel'
3
- import type { Kysely } from 'kysely'
4
- import type { KyselyPikkuDB } from './kysely-tables.js'
5
- import { parseJson } from './kysely-json.js'
6
- import { requirePikkuSchema } from './schema/index.js'
7
- import { channelSchema } from './schema/channel.schema.js'
8
-
9
- export class KyselyChannelStore extends ChannelStore {
10
- private initialized = false
11
-
12
- constructor(private db: Kysely<KyselyPikkuDB>) {
13
- super()
14
- }
15
-
16
- public async init(): Promise<void> {
17
- if (this.initialized) return
18
- await requirePikkuSchema(this.db, channelSchema)
19
- this.initialized = true
20
- }
21
-
22
- public async addChannel({
23
- channelId,
24
- channelName,
25
- openingData,
26
- }: Channel): Promise<void> {
27
- await this.db
28
- .insertInto('channels')
29
- .values({
30
- channelId: channelId,
31
- channelName: channelName,
32
- openingData: JSON.stringify(openingData || {}),
33
- })
34
- .execute()
35
- }
36
-
37
- public async removeChannels(channelIds: string[]): Promise<void> {
38
- if (channelIds.length === 0) {
39
- return
40
- }
41
-
42
- await this.db
43
- .deleteFrom('channels')
44
- .where('channelId', 'in', channelIds)
45
- .execute()
46
- }
47
-
48
- public async setPikkuUserId(
49
- channelId: string,
50
- pikkuUserId: string | null
51
- ): Promise<void> {
52
- await this.db
53
- .updateTable('channels')
54
- .set({ pikkuUserId })
55
- .where('channelId', '=', channelId)
56
- .execute()
57
- }
58
-
59
- public async getChannel(
60
- channelId: string
61
- ): Promise<Channel & { pikkuUserId?: string }> {
62
- const row = await this.db
63
- .selectFrom('channels')
64
- .select(['channelId', 'channelName', 'openingData', 'pikkuUserId'])
65
- .where('channelId', '=', channelId)
66
- .executeTakeFirst()
67
-
68
- if (!row) {
69
- throw new Error(`Channel not found: ${channelId}`)
70
- }
71
-
72
- return {
73
- channelId: row.channelId,
74
- channelName: row.channelName,
75
- openingData: parseJson(row.openingData) ?? {},
76
- pikkuUserId: row.pikkuUserId ?? undefined,
77
- }
78
- }
79
-
80
- public async setState(channelId: string, state: unknown): Promise<void> {
81
- await this.db
82
- .updateTable('channels')
83
- .set({ state: JSON.stringify(state ?? null) })
84
- .where('channelId', '=', channelId)
85
- .execute()
86
- }
87
-
88
- public async getState(channelId: string): Promise<unknown | undefined> {
89
- const row = await this.db
90
- .selectFrom('channels')
91
- .select(['state'])
92
- .where('channelId', '=', channelId)
93
- .executeTakeFirst()
94
- if (!row || !row.state) return undefined
95
- return parseJson(row.state) ?? undefined
96
- }
97
-
98
- public async clearState(channelId: string): Promise<void> {
99
- await this.db
100
- .updateTable('channels')
101
- .set({ state: null })
102
- .where('channelId', '=', channelId)
103
- .execute()
104
- }
105
-
106
- public async close(): Promise<void> {}
107
- }