@pikku/kysely 0.13.7 → 0.13.11

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 (41) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/dist/src/index.d.ts +4 -1
  3. package/dist/src/index.js +3 -1
  4. package/dist/src/kysely-audit-service.d.ts +34 -2
  5. package/dist/src/kysely-audit-service.js +163 -3
  6. package/dist/src/kysely-secret-service.d.ts +4 -3
  7. package/dist/src/kysely-secret-service.js +6 -3
  8. package/dist/src/kysely-tables.d.ts +31 -0
  9. package/dist/src/kysely-virtual-user-run-store.d.ts +43 -0
  10. package/dist/src/kysely-virtual-user-run-store.js +131 -0
  11. package/dist/src/kysely-workflow-service.d.ts +1 -0
  12. package/dist/src/kysely-workflow-service.js +22 -0
  13. package/dist/src/schema/audit.schema.d.ts +16 -0
  14. package/dist/src/schema/audit.schema.js +47 -0
  15. package/dist/src/schema/credential.schema.d.ts +3 -2
  16. package/dist/src/schema/credential.schema.js +4 -3
  17. package/dist/src/schema/index.d.ts +17 -4
  18. package/dist/src/schema/index.js +40 -6
  19. package/dist/src/schema/pikku-schema.types.d.ts +41 -5
  20. package/dist/src/schema/pikku-schema.types.js +27 -12
  21. package/dist/src/schema/virtual-user.schema.d.ts +19 -0
  22. package/dist/src/schema/virtual-user.schema.js +54 -0
  23. package/dist/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +2 -2
  25. package/src/create-audited-kysely.test.ts +10 -10
  26. package/src/index.ts +7 -0
  27. package/src/kysely-audit-service.test.ts +360 -0
  28. package/src/kysely-audit-service.ts +181 -4
  29. package/src/kysely-secret-service.ts +11 -10
  30. package/src/kysely-services.test.ts +3 -3
  31. package/src/kysely-tables.test.ts +12 -1
  32. package/src/kysely-tables.ts +32 -0
  33. package/src/kysely-virtual-user-run-store.test.ts +212 -0
  34. package/src/kysely-virtual-user-run-store.ts +149 -0
  35. package/src/kysely-workflow-service.ts +39 -0
  36. package/src/schema/audit.schema.ts +55 -0
  37. package/src/schema/credential.schema.ts +5 -3
  38. package/src/schema/index.ts +59 -10
  39. package/src/schema/pikku-schema.types.ts +65 -10
  40. package/src/schema/schema.test.ts +82 -4
  41. package/src/schema/virtual-user.schema.ts +65 -0
@@ -33,8 +33,8 @@ interface AuditEventTable {
33
33
  traceId: string | null
34
34
  transactionId: string | null
35
35
  queryId: string | null
36
- actorUserId: string | null
37
- actorOrgId: string | null
36
+ userId: string | null
37
+ orgId: string | null
38
38
  actorPikkuUserId: string | null
39
39
  metadataJson: string | null
40
40
  }
@@ -76,8 +76,8 @@ async function createAuditEventsTable(
76
76
  .addColumn('traceId', 'text')
77
77
  .addColumn('transactionId', 'text')
78
78
  .addColumn('queryId', 'text')
79
- .addColumn('actorUserId', 'text')
80
- .addColumn('actorOrgId', 'text')
79
+ .addColumn('userId', 'text')
80
+ .addColumn('orgId', 'text')
81
81
  .addColumn('actorPikkuUserId', 'text')
82
82
  .addColumn('metadataJson', 'text')
83
83
  .execute()
@@ -113,9 +113,9 @@ class ImmediateAuditService implements AuditService {
113
113
  traceId: event.traceId ?? null,
114
114
  transactionId: event.transactionId ?? null,
115
115
  queryId: event.queryId ?? null,
116
- actorUserId: event.actor?.userId ?? null,
117
- actorOrgId: event.actor?.orgId ?? null,
118
- actorPikkuUserId: event.actor?.pikkuUserId ?? null,
116
+ userId: event.userIdentity?.userId ?? null,
117
+ orgId: event.userIdentity?.orgId ?? null,
118
+ actorPikkuUserId: event.userIdentity?.pikkuUserId ?? null,
119
119
  metadataJson: event.metadata ? JSON.stringify(event.metadata) : null,
120
120
  })
121
121
  .execute()
@@ -207,7 +207,7 @@ describe('createAuditedKysely', () => {
207
207
  changes: { title: 'write tests', done: 0 },
208
208
  rowCount: 1,
209
209
  })
210
- assert.deepEqual(events[0]?.actor, {
210
+ assert.deepEqual(events[0]?.userIdentity, {
211
211
  userId: 'user-1',
212
212
  orgId: 'org-1',
213
213
  pikkuUserId: 'user-1',
@@ -257,7 +257,7 @@ describe('createAuditedKysely', () => {
257
257
  title: 'after',
258
258
  done: 1,
259
259
  })
260
- assert.deepEqual(events[0]?.actor, {
260
+ assert.deepEqual(events[0]?.userIdentity, {
261
261
  userId: 'user-2',
262
262
  orgId: undefined,
263
263
  pikkuUserId: 'user-2',
@@ -455,7 +455,7 @@ describe('createAuditedKysely', () => {
455
455
  ['todos:best-effort', 'todos:best-effort']
456
456
  )
457
457
  assert.deepEqual(
458
- persisted.map((event) => event.actorUserId),
458
+ persisted.map((event) => event.userId),
459
459
  ['user-best-effort', 'user-best-effort']
460
460
  )
461
461
  assert.equal(persisted[0]?.queryId, 'q-1')
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export {
19
19
  type CreateAuditedKyselyOptions,
20
20
  } from './create-audited-kysely.js'
21
21
  export { KyselyAuditService } from './kysely-audit-service.js'
22
+ export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js'
22
23
 
23
24
  export {
24
25
  SerializePlugin,
@@ -34,7 +35,9 @@ export {
34
35
  type SerializePluginOptions,
35
36
  } from './serialize-plugin.js'
36
37
 
38
+ export { virtualUserSchema } from './schema/virtual-user.schema.js'
37
39
  export {
40
+ auditSchema,
38
41
  pikkuSchemas,
39
42
  applyPikkuSchemas,
40
43
  compilePikkuSchemas,
@@ -55,3 +58,7 @@ export {
55
58
  export type { KyselyPikkuDB } from './kysely-tables.js'
56
59
  export type { WorkflowRunService } from '@pikku/core/workflow'
57
60
  export type { AgentRunService, AgentRunRow } from '@pikku/core/ai-agent'
61
+ export type {
62
+ VirtualUserRunStore,
63
+ VirtualUserRunRecord,
64
+ } from '@pikku/core/virtual-user'
@@ -0,0 +1,360 @@
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 '../../../core/src/index.js'
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('creates the audit table on 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 freshService.init()
319
+ await freshService.write([
320
+ event({ eventId: 'e1', type: 't', occurredAt: '2026-01-01' }),
321
+ ])
322
+ const { events } = await freshService.query()
323
+ assert.deepEqual(
324
+ events.map((e) => e.eventId),
325
+ ['e1']
326
+ )
327
+ })
328
+
329
+ // Two boots against one database, and the second must not wipe the first.
330
+ test('is idempotent and keeps what is already there', async () => {
331
+ await service.write([
332
+ event({ eventId: 'e1', type: 't', occurredAt: '2026-01-01' }),
333
+ ])
334
+ await service.init()
335
+ await service.init()
336
+ const { events } = await service.query()
337
+ assert.deepEqual(
338
+ events.map((e) => e.eventId),
339
+ ['e1']
340
+ )
341
+ })
342
+ })
343
+
344
+ describe('KyselyAuditService.facets', () => {
345
+ test('lists the distinct users and types across the whole trail', async () => {
346
+ await seed()
347
+ const facets = await service.facets()
348
+ assert.deepEqual(facets.userIds, ['alice', 'bob'])
349
+ assert.deepEqual(facets.types, ['invoice.delete', 'invoice.update'])
350
+ })
351
+
352
+ test('omits userless events from the user list', async () => {
353
+ await service.write([
354
+ event({ eventId: 'cron', type: 'cleanup.ran', occurredAt: '2026-05-01' }),
355
+ ])
356
+ const facets = await service.facets()
357
+ assert.deepEqual(facets.userIds, [])
358
+ assert.deepEqual(facets.types, ['cleanup.ran'])
359
+ })
360
+ })
@@ -1,9 +1,78 @@
1
- import type { AuditEvent, AuditEventBatch, AuditService } from '@pikku/core'
1
+ import type {
2
+ AuditEvent,
3
+ AuditEventBatch,
4
+ AuditFacets,
5
+ AuditQuery,
6
+ AuditQueryResult,
7
+ AuditService,
8
+ } from '@pikku/core'
2
9
  import type { Kysely } from 'kysely'
10
+ import { ensurePikkuSchema } from './schema/index.js'
11
+ import { auditSchema } from './schema/audit.schema.js'
3
12
 
4
13
  const jsonOrNull = (v: unknown): string | null =>
5
14
  v != null ? JSON.stringify(v) : null
6
15
 
16
+ const parseJson = (v: unknown): any => {
17
+ if (typeof v !== 'string') return v ?? undefined
18
+ try {
19
+ return JSON.parse(v)
20
+ } catch {
21
+ // A row written by something other than this service, or truncated in
22
+ // transit. Surfacing the raw text beats dropping the event: the row is
23
+ // still evidence that something happened, and swallowing it would make the
24
+ // trail quietly incomplete.
25
+ return v
26
+ }
27
+ }
28
+
29
+ /**
30
+ * A column by its physical name, whatever the connection renamed it to.
31
+ *
32
+ * `CamelCasePlugin` is on most pikku Kysely instances and rewrites result keys
33
+ * on the way out, so the same row arrives keyed `audit_id` or `auditId`
34
+ * depending on a plugin this service is never told about. Reading both is
35
+ * cheaper than demanding a particular connection — and quieter to get wrong,
36
+ * since the mismatch does not throw, it just returns a page of undefined.
37
+ */
38
+ const column = (row: Record<string, any>, name: string): any => {
39
+ const value = row[name]
40
+ if (value !== undefined) return value
41
+ return row[name.replace(/_(\w)/g, (_, char: string) => char.toUpperCase())]
42
+ }
43
+
44
+ /** Rows are TEXT on every engine, so the shape is whatever the driver returned. */
45
+ const rowToEvent = (row: Record<string, any>): AuditEvent => {
46
+ const userId = column(row, 'user_id')
47
+ const orgId = column(row, 'org_id')
48
+ const pikkuUserId = column(row, 'pikku_user_id')
49
+ return {
50
+ eventId: column(row, 'audit_id') ?? undefined,
51
+ occurredAt: column(row, 'occurred_at'),
52
+ type: row.type,
53
+ source: row.source ?? 'auto',
54
+ outcome: row.outcome ?? undefined,
55
+ functionId: column(row, 'function_id') ?? undefined,
56
+ wireType: column(row, 'wire_type') ?? undefined,
57
+ traceId: column(row, 'trace_id') ?? undefined,
58
+ transactionId: column(row, 'transaction_id') ?? undefined,
59
+ queryId: column(row, 'query_id') ?? undefined,
60
+ userIdentity:
61
+ userId || orgId || pikkuUserId
62
+ ? {
63
+ userId: userId ?? undefined,
64
+ orgId: orgId ?? undefined,
65
+ pikkuUserId: pikkuUserId ?? undefined,
66
+ }
67
+ : undefined,
68
+ metadata: parseJson(row.data),
69
+ }
70
+ }
71
+
72
+ /** The most a single page may return, whatever the caller asked for. */
73
+ const MAX_LIMIT = 200
74
+ const DEFAULT_LIMIT = 50
75
+
7
76
  // No global `crypto` is guaranteed across every runtime, and audit_id is the
8
77
  // PK — any collision is dropped by ON CONFLICT DO NOTHING.
9
78
  const fallbackId = (): string =>
@@ -15,14 +84,30 @@ const fallbackId = (): string =>
15
84
  *
16
85
  * The column mapping matches a platform audit-queue consumer, so a locally-run
17
86
  * project and a deployed stage write identical rows (the read side relies only
18
- * on `type`, `occurred_at`, `actor_user_id`, and the `data` JSON that holds
87
+ * on `type`, `occurred_at`, `user_id`, and the `data` JSON that holds
19
88
  * `metadata`). The `audit` table is an optional per-project migration (not in
20
89
  * the generated schema), so rows are inserted untyped; all columns are TEXT on
21
90
  * every engine and ON CONFLICT DO NOTHING keeps writes idempotent on retries.
22
91
  */
23
92
  export class KyselyAuditService implements AuditService {
93
+ private initialized = false
94
+
24
95
  constructor(private db: Kysely<any>) {}
25
96
 
97
+ /**
98
+ * Creates the `audit` table if the database has none.
99
+ *
100
+ * Optional — a project that migrates the table itself can skip it, and the
101
+ * declaration is written to match the documented shape either way. Calling it
102
+ * on a database that already has the table is a no-op, so two instances
103
+ * booting cold do not race each other into a half-applied schema.
104
+ */
105
+ public async init(): Promise<void> {
106
+ if (this.initialized) return
107
+ await ensurePikkuSchema(this.db, auditSchema)
108
+ this.initialized = true
109
+ }
110
+
26
111
  async audit(event: AuditEvent): Promise<void> {
27
112
  await this.write([event])
28
113
  }
@@ -44,8 +129,9 @@ export class KyselyAuditService implements AuditService {
44
129
  trace_id: e.traceId ?? null,
45
130
  transaction_id: e.transactionId ?? null,
46
131
  query_id: e.queryId ?? null,
47
- actor_user_id: e.actor?.userId ?? null,
48
- actor_org_id: e.actor?.orgId ?? null,
132
+ user_id: e.userIdentity?.userId ?? null,
133
+ org_id: e.userIdentity?.orgId ?? null,
134
+ pikku_user_id: e.userIdentity?.pikkuUserId ?? null,
49
135
  tables: jsonOrNull(metadata?.tables),
50
136
  changed_cols: jsonOrNull(metadata?.changedColumns),
51
137
  event: e.eventId ?? null,
@@ -59,4 +145,95 @@ export class KyselyAuditService implements AuditService {
59
145
  .onConflict((oc: any) => oc.doNothing())
60
146
  .execute()
61
147
  }
148
+
149
+ /**
150
+ * A page of the trail, newest first.
151
+ *
152
+ * Offset paging, matching the rest of the console. The trail is append-only
153
+ * and ordered by `occurred_at DESC`, so events written *while* a reader is
154
+ * paging shift the window and can repeat a row across pages. That is visible
155
+ * but harmless for browsing; anything that must not miss a row should page by
156
+ * a bounded `from`/`to` window instead of scrolling.
157
+ *
158
+ * Ties on `occurred_at` are broken by `audit_id` so the order is total —
159
+ * without it a batch flushed in one call shares a timestamp and the engine is
160
+ * free to order it differently per query, which drops or repeats rows at a
161
+ * page boundary.
162
+ */
163
+ async query(query: AuditQuery = {}): Promise<AuditQueryResult> {
164
+ const limit = Math.min(Math.max(query.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT)
165
+ const offset = Math.max(query.offset ?? 0, 0)
166
+
167
+ let builder = (this.db as any).selectFrom('audit').selectAll()
168
+ builder = this.applyFilters(builder, query)
169
+
170
+ const rows = await builder
171
+ .orderBy('occurred_at', 'desc')
172
+ .orderBy('audit_id', 'desc')
173
+ // One extra row is the cheapest honest way to know whether another page
174
+ // exists — a COUNT would be a second scan of the same filter.
175
+ .limit(limit + 1)
176
+ .offset(offset)
177
+ .execute()
178
+
179
+ const hasMore = rows.length > limit
180
+ return {
181
+ events: rows.slice(0, limit).map(rowToEvent),
182
+ nextCursor: hasMore ? offset + limit : null,
183
+ }
184
+ }
185
+
186
+ async facets(): Promise<AuditFacets> {
187
+ const [users, types] = await Promise.all([
188
+ (this.db as any)
189
+ .selectFrom('audit')
190
+ .select('user_id')
191
+ .distinct()
192
+ .where('user_id', 'is not', null)
193
+ .orderBy('user_id', 'asc')
194
+ .execute(),
195
+ (this.db as any)
196
+ .selectFrom('audit')
197
+ .select('type')
198
+ .distinct()
199
+ .orderBy('type', 'asc')
200
+ .execute(),
201
+ ])
202
+ return {
203
+ userIds: users.map((r: any) => column(r, 'user_id')),
204
+ types: types.map((r: any) => r.type).filter(Boolean),
205
+ }
206
+ }
207
+
208
+ /**
209
+ * An empty array means "match nothing" and is applied as such. Treating it as
210
+ * "no filter" would turn a reader's deselect-everything into a full-trail
211
+ * read, which is the opposite of what they asked for.
212
+ */
213
+ private applyFilters(builder: any, query: AuditQuery): any {
214
+ // `in ()` is a syntax error on Postgres and SQLite alike, so an empty
215
+ // selection is expressed as a predicate that cannot hold.
216
+ const matchNothing = (b: any) => b.where('audit_id', 'is', null)
217
+
218
+ if (query.userIds) {
219
+ builder = query.userIds.length
220
+ ? builder.where('user_id', 'in', query.userIds)
221
+ : matchNothing(builder)
222
+ }
223
+ if (query.types) {
224
+ builder = query.types.length
225
+ ? builder.where('type', 'in', query.types)
226
+ : matchNothing(builder)
227
+ }
228
+ if (query.orgId) {
229
+ builder = builder.where('org_id', '=', query.orgId)
230
+ }
231
+ if (query.from) {
232
+ builder = builder.where('occurred_at', '>=', query.from)
233
+ }
234
+ if (query.to) {
235
+ builder = builder.where('occurred_at', '<', query.to)
236
+ }
237
+ return builder
238
+ }
62
239
  }