@pikku/kysely 0.12.11 → 0.12.13

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 CHANGED
@@ -1,3 +1,23 @@
1
+ ## 0.12.13
2
+
3
+ ### Patch Changes
4
+
5
+ - 909eb25: Add audit logging support for function invocations and database queries.
6
+
7
+ Introduces `AuditService` and `createAuditedKysely` — configurable audit capture with best-effort and transactional durability modes. Audit logs capture session metadata (user, org), RPC call details, and Kysely query operations (type, tables, changes). Audit context is scoped per-invocation so nested RPC calls are correctly attributed.
8
+
9
+ - Updated dependencies [909eb25]
10
+ - @pikku/core@0.12.26
11
+
12
+ ## 0.12.12
13
+
14
+ ### Patch Changes
15
+
16
+ - 55ba75a: Update the OSS runtime dependency set for newer Node support by bumping `uWebSockets.js` to `v20.68.0` and `better-sqlite3` to `v12.10.0`.
17
+ - Updated dependencies [c02275f]
18
+ - Updated dependencies [0bd0433]
19
+ - @pikku/core@0.12.24
20
+
1
21
  ## 0.12.11
2
22
 
3
23
  ### Patch Changes
@@ -0,0 +1,10 @@
1
+ import type { AuditLog } from '@pikku/core';
2
+ import type { Kysely } from 'kysely';
3
+ export interface CreateAuditedKyselyOptions {
4
+ audit: AuditLog;
5
+ auditReads?: boolean;
6
+ eventType?: string;
7
+ transactionId?: string | null;
8
+ queryIdPrefix?: string;
9
+ }
10
+ export declare function createAuditedKysely<DB>(db: Kysely<DB>, options: CreateAuditedKyselyOptions): Kysely<DB>;
@@ -0,0 +1,167 @@
1
+ const QUERY_KIND_BY_NODE = {
2
+ SelectQueryNode: 'select',
3
+ InsertQueryNode: 'insert',
4
+ UpdateQueryNode: 'update',
5
+ DeleteQueryNode: 'delete',
6
+ };
7
+ function getIdentifierName(node) {
8
+ if (!node || typeof node !== 'object')
9
+ return undefined;
10
+ const candidate = node;
11
+ return (candidate.name ??
12
+ candidate.column?.name ??
13
+ candidate.identifier?.name ??
14
+ candidate.table?.identifier?.name);
15
+ }
16
+ function collectTableNames(node, out) {
17
+ if (!node || typeof node !== 'object')
18
+ return;
19
+ const candidate = node;
20
+ if (candidate.kind === 'TableNode' &&
21
+ typeof candidate.table?.identifier?.name === 'string') {
22
+ out.add(candidate.table.identifier.name);
23
+ }
24
+ for (const value of Object.values(node)) {
25
+ if (Array.isArray(value)) {
26
+ for (const item of value)
27
+ collectTableNames(item, out);
28
+ continue;
29
+ }
30
+ collectTableNames(value, out);
31
+ }
32
+ }
33
+ function extractValue(node) {
34
+ if (!node || typeof node !== 'object')
35
+ return node;
36
+ const candidate = node;
37
+ switch (candidate.kind) {
38
+ case 'ValueNode':
39
+ return candidate.value;
40
+ case 'PrimitiveValueListNode':
41
+ case 'ValueListNode':
42
+ return (candidate.values ?? []).map(extractValue);
43
+ case 'DefaultInsertValueNode':
44
+ return '[default]';
45
+ case 'ColumnNode':
46
+ case 'ReferenceNode':
47
+ return `[${candidate.kind}]`;
48
+ case 'RawNode':
49
+ return '[raw]';
50
+ case 'SelectQueryNode':
51
+ return '[subquery]';
52
+ case 'ParensNode':
53
+ return extractValue(candidate.node);
54
+ case 'BinaryOperationNode':
55
+ return {
56
+ left: extractValue(candidate.left),
57
+ right: extractValue(candidate.right),
58
+ };
59
+ default:
60
+ return `[${candidate.kind ?? 'expression'}]`;
61
+ }
62
+ }
63
+ function extractInsertChanges(node) {
64
+ const columns = Array.isArray(node.columns)
65
+ ? node.columns
66
+ .map((column) => getIdentifierName(column))
67
+ .filter(Boolean)
68
+ : [];
69
+ const values = Array.isArray(node.values?.values) ? node.values.values : [];
70
+ if (columns.length === 0 || values.length === 0) {
71
+ return {
72
+ changedColumns: columns.length > 0 ? columns : undefined,
73
+ };
74
+ }
75
+ const rows = values.map((row) => {
76
+ const rowValues = Array.isArray(row?.values)
77
+ ? row.values.map(extractValue)
78
+ : [];
79
+ return Object.fromEntries(columns.map((column, index) => [column, rowValues[index]]));
80
+ });
81
+ return {
82
+ changedColumns: columns,
83
+ changes: rows.length === 1 ? rows[0] : rows,
84
+ };
85
+ }
86
+ function extractUpdateChanges(node) {
87
+ const updates = Array.isArray(node.updates) ? node.updates : [];
88
+ const entries = updates
89
+ .map((update) => {
90
+ const column = getIdentifierName(update.column);
91
+ if (!column)
92
+ return null;
93
+ return [column, extractValue(update.value)];
94
+ })
95
+ .filter(Boolean);
96
+ if (entries.length === 0) {
97
+ return {};
98
+ }
99
+ return {
100
+ changedColumns: entries.map(([column]) => column),
101
+ changes: Object.fromEntries(entries),
102
+ };
103
+ }
104
+ function extractChangeSet(node, queryKind) {
105
+ if (queryKind === 'insert') {
106
+ return extractInsertChanges(node);
107
+ }
108
+ if (queryKind === 'update') {
109
+ return extractUpdateChanges(node);
110
+ }
111
+ return {};
112
+ }
113
+ class AuditedKyselyPlugin {
114
+ options;
115
+ pending = new Map();
116
+ queryCounter = 0;
117
+ constructor(options) {
118
+ this.options = options;
119
+ }
120
+ transformQuery(args) {
121
+ const queryKind = QUERY_KIND_BY_NODE[args.node.kind ?? ''];
122
+ if (!queryKind) {
123
+ return args.node;
124
+ }
125
+ if (queryKind === 'select' && this.options.auditReads === false) {
126
+ return args.node;
127
+ }
128
+ const tables = new Set();
129
+ collectTableNames(args.node, tables);
130
+ const queryId = `${this.options.queryIdPrefix ?? 'q'}-${++this.queryCounter}`;
131
+ this.pending.set(args.queryId, {
132
+ queryKind,
133
+ queryId,
134
+ tables: [...tables].sort(),
135
+ ...extractChangeSet(args.node, queryKind),
136
+ });
137
+ return args.node;
138
+ }
139
+ async transformResult(args) {
140
+ const pending = this.pending.get(args.queryId);
141
+ if (!pending) {
142
+ return args.result;
143
+ }
144
+ this.pending.delete(args.queryId);
145
+ const rawResult = args.result;
146
+ const rowCount = rawResult.rows.length > 0
147
+ ? rawResult.rows.length
148
+ : Number(rawResult.numUpdatedOrDeletedRows ?? rawResult.numAffectedRows ?? 0n);
149
+ await this.options.audit.write({
150
+ type: this.options.eventType ?? 'db.query',
151
+ source: 'auto',
152
+ transactionId: this.options.transactionId ?? null,
153
+ queryId: pending.queryId,
154
+ metadata: {
155
+ queryKind: pending.queryKind,
156
+ tables: pending.tables,
157
+ changedColumns: pending.changedColumns,
158
+ changes: pending.changes,
159
+ rowCount,
160
+ },
161
+ });
162
+ return rawResult;
163
+ }
164
+ }
165
+ export function createAuditedKysely(db, options) {
166
+ return db.withPlugin(new AuditedKyselyPlugin(options));
167
+ }
@@ -2,6 +2,7 @@ export { KyselyChannelStore } from './kysely-channel-store.js';
2
2
  export { KyselyEventHubStore } from './kysely-eventhub-store.js';
3
3
  export { KyselyWorkflowService } from './kysely-workflow-service.js';
4
4
  export { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
5
+ export { KyselyWorkflowMirror } from './kysely-workflow-mirror.js';
5
6
  export { KyselyDeploymentService } from './kysely-deployment-service.js';
6
7
  export { KyselyAIStorageService } from './kysely-ai-storage-service.js';
7
8
  export { KyselyAgentRunService } from './kysely-ai-agent-run-service.js';
@@ -11,6 +12,7 @@ export type { KyselySecretServiceConfig } from './kysely-secret-service.js';
11
12
  export { KyselyCredentialService } from './kysely-credential-service.js';
12
13
  export type { KyselyCredentialServiceConfig } from './kysely-credential-service.js';
13
14
  export { KyselySessionStore } from './kysely-session-store.js';
15
+ export { createAuditedKysely, type CreateAuditedKyselyOptions, } from './create-audited-kysely.js';
14
16
  export type { KyselyPikkuDB } from './kysely-tables.js';
15
17
  export type { WorkflowRunService } from '@pikku/core/workflow';
16
18
  export type { AgentRunService, AgentRunRow } from '@pikku/core/ai-agent';
package/dist/src/index.js CHANGED
@@ -2,6 +2,7 @@ export { KyselyChannelStore } from './kysely-channel-store.js';
2
2
  export { KyselyEventHubStore } from './kysely-eventhub-store.js';
3
3
  export { KyselyWorkflowService } from './kysely-workflow-service.js';
4
4
  export { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
5
+ export { KyselyWorkflowMirror } from './kysely-workflow-mirror.js';
5
6
  export { KyselyDeploymentService } from './kysely-deployment-service.js';
6
7
  export { KyselyAIStorageService } from './kysely-ai-storage-service.js';
7
8
  export { KyselyAgentRunService } from './kysely-ai-agent-run-service.js';
@@ -9,3 +10,4 @@ export { KyselyAIRunStateService } from './kysely-ai-run-state-service.js';
9
10
  export { KyselySecretService } from './kysely-secret-service.js';
10
11
  export { KyselyCredentialService } from './kysely-credential-service.js';
11
12
  export { KyselySessionStore } from './kysely-session-store.js';
13
+ export { createAuditedKysely, } from './create-audited-kysely.js';
@@ -6,6 +6,7 @@ export declare class KyselyAgentRunService implements AgentRunService {
6
6
  constructor(db: Kysely<KyselyPikkuDB>);
7
7
  listThreads(options?: {
8
8
  agentName?: string;
9
+ resourceId?: string;
9
10
  limit?: number;
10
11
  offset?: number;
11
12
  }): Promise<AIThread[]>;
@@ -5,7 +5,7 @@ export class KyselyAgentRunService {
5
5
  this.db = db;
6
6
  }
7
7
  async listThreads(options) {
8
- const { agentName, limit = 50, offset = 0 } = options ?? {};
8
+ const { agentName, resourceId, limit = 50, offset = 0 } = options ?? {};
9
9
  let query = this.db
10
10
  .selectFrom('aiThreads as t')
11
11
  .select([
@@ -23,6 +23,9 @@ export class KyselyAgentRunService {
23
23
  .where('agentName', '=', agentName)
24
24
  .distinct());
25
25
  }
26
+ if (resourceId) {
27
+ query = query.where('t.resourceId', '=', resourceId);
28
+ }
26
29
  const result = await query
27
30
  .orderBy('t.updatedAt', 'desc')
28
31
  .limit(limit)
@@ -13,5 +13,8 @@ export declare class KyselyChannelStore extends ChannelStore {
13
13
  getChannel(channelId: string): Promise<Channel & {
14
14
  pikkuUserId?: string;
15
15
  }>;
16
+ setState(channelId: string, state: unknown): Promise<void>;
17
+ getState(channelId: string): Promise<unknown | undefined>;
18
+ clearState(channelId: string): Promise<void>;
16
19
  close(): Promise<void>;
17
20
  }
@@ -20,6 +20,7 @@ export class KyselyChannelStore extends ChannelStore {
20
20
  .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
21
21
  .addColumn('opening_data', 'text', (col) => col.notNull().defaultTo('{}'))
22
22
  .addColumn('pikku_user_id', 'text')
23
+ .addColumn('state', 'text')
23
24
  .addColumn('last_wire', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
24
25
  .execute();
25
26
  await this.db.schema
@@ -76,5 +77,29 @@ export class KyselyChannelStore extends ChannelStore {
76
77
  pikkuUserId: row.pikkuUserId ?? undefined,
77
78
  };
78
79
  }
80
+ async setState(channelId, state) {
81
+ await this.db
82
+ .updateTable('channels')
83
+ .set({ state: JSON.stringify(state ?? null) })
84
+ .where('channelId', '=', channelId)
85
+ .execute();
86
+ }
87
+ async getState(channelId) {
88
+ const row = await this.db
89
+ .selectFrom('channels')
90
+ .select(['state'])
91
+ .where('channelId', '=', channelId)
92
+ .executeTakeFirst();
93
+ if (!row || !row.state)
94
+ return undefined;
95
+ return parseJson(row.state) ?? undefined;
96
+ }
97
+ async clearState(channelId) {
98
+ await this.db
99
+ .updateTable('channels')
100
+ .set({ state: null })
101
+ .where('channelId', '=', channelId)
102
+ .execute();
103
+ }
79
104
  async close() { }
80
105
  }
@@ -20,10 +20,9 @@ export declare class KyselySecretService implements SecretService {
20
20
  init(): Promise<void>;
21
21
  private logAudit;
22
22
  private getKEK;
23
- getSecret(key: string): Promise<string>;
24
- getSecretJSON<R = {}>(key: string): Promise<R>;
23
+ getSecret<T = string>(key: string): Promise<T>;
25
24
  hasSecret(key: string): Promise<boolean>;
26
- setSecretJSON(key: string, value: unknown): Promise<void>;
25
+ setSecret(key: string, value: unknown): Promise<void>;
27
26
  deleteSecret(key: string): Promise<void>;
28
27
  rotateKEK(): Promise<number>;
29
28
  }
@@ -76,10 +76,6 @@ export class KyselySecretService {
76
76
  await this.logAudit(key, 'read');
77
77
  return result;
78
78
  }
79
- async getSecretJSON(key) {
80
- const raw = await this.getSecret(key);
81
- return JSON.parse(raw);
82
- }
83
79
  async hasSecret(key) {
84
80
  const row = await this.db
85
81
  .selectFrom('secrets')
@@ -88,9 +84,8 @@ export class KyselySecretService {
88
84
  .executeTakeFirst();
89
85
  return !!row;
90
86
  }
91
- async setSecretJSON(key, value) {
92
- const plaintext = JSON.stringify(value);
93
- const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.key, plaintext);
87
+ async setSecret(key, value) {
88
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.key, value);
94
89
  const now = new Date().toISOString();
95
90
  await this.db
96
91
  .insertInto('secrets')
@@ -6,6 +6,8 @@ export interface ChannelsTable {
6
6
  createdAt: Generated<Date>;
7
7
  openingData: string;
8
8
  pikkuUserId: string | null;
9
+ /** JSON-serialized per-socket channel state, keyed by channelId. */
10
+ state: string | null;
9
11
  lastWire: Generated<Date>;
10
12
  }
11
13
  export interface ChannelSubscriptionsTable {
@@ -0,0 +1,60 @@
1
+ import type { SerializedError } from '@pikku/core';
2
+ import type { StepState, WorkflowRunMirror, WorkflowRunWire, WorkflowStatus, WorkflowPlannedStep, WorkflowVersionStatus } from '@pikku/core/workflow';
3
+ import type { Kysely } from 'kysely';
4
+ import type { KyselyPikkuDB } from './kysely-tables.js';
5
+ /**
6
+ * Kysely-backed `WorkflowRunMirror`.
7
+ *
8
+ * Forwards executor writes to the same `workflow_runs` / `workflow_step` /
9
+ * `workflow_step_history` / `workflow_versions` tables that
10
+ * `KyselyWorkflowService` uses, so a `KyselyWorkflowRunService` reading
11
+ * those tables will see runs driven by any executor (Cloudflare Durable
12
+ * Object, Redis, MongoDB, in-memory) — not just kysely-driven runs.
13
+ *
14
+ * Errors thrown here are caught by the executor's `safeMirror` wrapper
15
+ * and logged, so a mirror outage cannot break a running workflow.
16
+ */
17
+ export declare class KyselyWorkflowMirror implements WorkflowRunMirror {
18
+ protected db: Kysely<KyselyPikkuDB>;
19
+ private initialized;
20
+ constructor(db: Kysely<KyselyPikkuDB>);
21
+ /**
22
+ * Create the underlying tables if they don't exist. Safe to call from
23
+ * either the mirror or `KyselyWorkflowService.init()` — schema is the
24
+ * same and uses `ifNotExists`.
25
+ */
26
+ init(): Promise<void>;
27
+ createRun(runId: string, workflowName: string, input: any, inline: boolean, graphHash: string, wire: WorkflowRunWire, options?: {
28
+ deterministic?: boolean;
29
+ plannedSteps?: WorkflowPlannedStep[];
30
+ }): Promise<void>;
31
+ updateRunStatus(id: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
32
+ insertStepState(runId: string, step: StepState & {
33
+ stepName: string;
34
+ rpcName: string | null;
35
+ data: any;
36
+ }): Promise<void>;
37
+ setStepRunning(stepId: string): Promise<void>;
38
+ setStepScheduled(stepId: string): Promise<void>;
39
+ setStepResult(stepId: string, result: any): Promise<void>;
40
+ setStepChildRunId(stepId: string, childRunId: string): Promise<void>;
41
+ setStepError(stepId: string, error: SerializedError): Promise<void>;
42
+ createRetryAttempt(failedStepId: string, newStep: StepState & {
43
+ stepName: string;
44
+ }): Promise<void>;
45
+ setBranchTaken(stepId: string, branchKey: string): Promise<void>;
46
+ updateRunState(runId: string, name: string, value: unknown): Promise<void>;
47
+ upsertWorkflowVersion(name: string, graphHash: string, graph: any, source: string, status?: WorkflowVersionStatus): Promise<void>;
48
+ updateWorkflowVersionStatus(name: string, graphHash: string, status: WorkflowVersionStatus): Promise<void>;
49
+ private updateStepStatus;
50
+ private insertHistoryRecord;
51
+ /**
52
+ * The executor pattern is: `insertStepState` writes a 'pending' history
53
+ * row, then later transitions UPDATE that latest row in place rather
54
+ * than appending a new one — except for retry attempts which append.
55
+ *
56
+ * We mirror the same shape so `KyselyWorkflowRunService.getRunHistory`
57
+ * returns the same sequence the executor would have produced.
58
+ */
59
+ private appendOrUpdateLatestHistory;
60
+ }