@pikku/kysely 0.13.23 → 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 (80) hide show
  1. package/CHANGELOG.md +248 -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/kysely-webhook-service.d.ts +1 -1
  10. package/dist/src/kysely-webhook-service.js +1 -1
  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 -124
  58. package/src/kysely-webhook-service.ts +0 -137
  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,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
- }