@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.
- package/CHANGELOG.md +202 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-audit-service.d.ts +34 -2
- package/dist/src/kysely-audit-service.js +163 -3
- package/dist/src/kysely-secret-service.d.ts +4 -3
- package/dist/src/kysely-secret-service.js +6 -3
- package/dist/src/kysely-tables.d.ts +31 -0
- package/dist/src/kysely-virtual-user-run-store.d.ts +43 -0
- package/dist/src/kysely-virtual-user-run-store.js +131 -0
- package/dist/src/kysely-workflow-service.d.ts +1 -0
- package/dist/src/kysely-workflow-service.js +22 -0
- package/dist/src/schema/audit.schema.d.ts +16 -0
- package/dist/src/schema/audit.schema.js +47 -0
- package/dist/src/schema/credential.schema.d.ts +3 -2
- package/dist/src/schema/credential.schema.js +4 -3
- package/dist/src/schema/index.d.ts +17 -4
- package/dist/src/schema/index.js +40 -6
- package/dist/src/schema/pikku-schema.types.d.ts +41 -5
- package/dist/src/schema/pikku-schema.types.js +27 -12
- package/dist/src/schema/virtual-user.schema.d.ts +19 -0
- package/dist/src/schema/virtual-user.schema.js +54 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/create-audited-kysely.test.ts +10 -10
- package/src/index.ts +7 -0
- package/src/kysely-audit-service.test.ts +360 -0
- package/src/kysely-audit-service.ts +181 -4
- package/src/kysely-secret-service.ts +11 -10
- package/src/kysely-services.test.ts +3 -3
- package/src/kysely-tables.test.ts +12 -1
- package/src/kysely-tables.ts +32 -0
- package/src/kysely-virtual-user-run-store.test.ts +212 -0
- package/src/kysely-virtual-user-run-store.ts +149 -0
- package/src/kysely-workflow-service.ts +39 -0
- package/src/schema/audit.schema.ts +55 -0
- package/src/schema/credential.schema.ts +5 -3
- package/src/schema/index.ts +59 -10
- package/src/schema/pikku-schema.types.ts +65 -10
- package/src/schema/schema.test.ts +82 -4
- 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
|
-
|
|
37
|
-
|
|
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('
|
|
80
|
-
.addColumn('
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
actorPikkuUserId: event.
|
|
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]?.
|
|
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]?.
|
|
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.
|
|
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 {
|
|
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`, `
|
|
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
|
-
|
|
48
|
-
|
|
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
|
}
|