@pikku/kysely 0.12.18 → 0.13.1

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.
@@ -0,0 +1,150 @@
1
+ import { PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, QueueWebhookService, } from '@pikku/core/services';
2
+ import { sql } from 'kysely';
3
+ /**
4
+ * Durable {@link QueueWebhookService}: still delivers through the
5
+ * `pikku-outgoing-webhooks` queue, but records a `webhook_delivery` row per
6
+ * `send()` and one `webhook_delivery_attempt` row per try. Register it as the
7
+ * `webhookService` singleton; the queue worker persists each attempt through
8
+ * {@link recordAttempt}, overriding the base's throwing default.
9
+ */
10
+ export class KyselyWebhookService extends QueueWebhookService {
11
+ db;
12
+ initialized = false;
13
+ constructor(queueService, db) {
14
+ super(queueService);
15
+ this.db = db;
16
+ }
17
+ async init() {
18
+ if (this.initialized) {
19
+ return;
20
+ }
21
+ await this.db.schema
22
+ .createTable('webhook_delivery')
23
+ .ifNotExists()
24
+ .addColumn('delivery_id', 'text', (col) => col.primaryKey())
25
+ .addColumn('organization_id', 'text')
26
+ .addColumn('url', 'text', (col) => col.notNull())
27
+ .addColumn('event', 'text')
28
+ .addColumn('status', 'text', (col) => col.defaultTo('pending').notNull())
29
+ .addColumn('attempts', 'integer', (col) => col.defaultTo(0).notNull())
30
+ .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
31
+ .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
32
+ .addColumn('delivered_at', 'timestamp')
33
+ .execute();
34
+ await this.db.schema
35
+ .createTable('webhook_delivery_attempt')
36
+ .ifNotExists()
37
+ .addColumn('attempt_id', 'text', (col) => col.primaryKey())
38
+ .addColumn('delivery_id', 'text', (col) => col.notNull().references('webhook_delivery.delivery_id').onDelete('cascade'))
39
+ .addColumn('attempt_number', 'integer', (col) => col.notNull())
40
+ .addColumn('status_code', 'integer')
41
+ .addColumn('response_body', 'text')
42
+ .addColumn('error', 'text')
43
+ .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
44
+ .execute();
45
+ await this.createIndexSafe(this.db.schema
46
+ .createIndex('idx_webhook_delivery_org')
47
+ .ifNotExists()
48
+ .on('webhook_delivery')
49
+ .column('organization_id'));
50
+ await this.createIndexSafe(this.db.schema
51
+ .createIndex('idx_webhook_delivery_attempt_delivery')
52
+ .ifNotExists()
53
+ .on('webhook_delivery_attempt')
54
+ .column('delivery_id'));
55
+ this.initialized = true;
56
+ }
57
+ async send(input) {
58
+ const deliveryId = globalThis.crypto.randomUUID();
59
+ const { jobData, options } = await this.prepareDelivery(input);
60
+ await this.db
61
+ .insertInto('webhookDelivery')
62
+ .values({
63
+ deliveryId,
64
+ organizationId: input.organizationId ?? null,
65
+ url: input.url,
66
+ event: input.event ?? null,
67
+ })
68
+ .execute();
69
+ // deliveryId doubles as the queue jobId (idempotency) and rides in the
70
+ // payload so the worker can record attempts against this row.
71
+ const jobId = await this.queueService.add(PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, { ...jobData, deliveryId }, { ...options, jobId: deliveryId });
72
+ return { jobId };
73
+ }
74
+ async recordAttempt(deliveryId, { statusCode, responseBody, error, delivered }) {
75
+ const now = new Date();
76
+ await this.db.transaction().execute(async (trx) => {
77
+ const existing = await trx
78
+ .selectFrom('webhookDeliveryAttempt')
79
+ .select((eb) => eb.fn.countAll().as('count'))
80
+ .where('deliveryId', '=', deliveryId)
81
+ .executeTakeFirst();
82
+ const attemptNumber = Number(existing?.count ?? 0) + 1;
83
+ await trx
84
+ .insertInto('webhookDeliveryAttempt')
85
+ .values({
86
+ attemptId: globalThis.crypto.randomUUID(),
87
+ deliveryId,
88
+ attemptNumber,
89
+ statusCode: statusCode ?? null,
90
+ responseBody: responseBody ?? null,
91
+ error: error ?? null,
92
+ })
93
+ .execute();
94
+ await trx
95
+ .updateTable('webhookDelivery')
96
+ .set({
97
+ status: delivered ? 'delivered' : 'failed',
98
+ attempts: attemptNumber,
99
+ updatedAt: now,
100
+ ...(delivered ? { deliveredAt: now } : {}),
101
+ })
102
+ .where('deliveryId', '=', deliveryId)
103
+ .execute();
104
+ });
105
+ }
106
+ /** List deliveries, most recent first — for the console webhooks view. */
107
+ async listDeliveries(opts) {
108
+ let query = this.db
109
+ .selectFrom('webhookDelivery')
110
+ .selectAll()
111
+ .orderBy('createdAt', 'desc')
112
+ .limit(opts?.limit ?? 100);
113
+ if (opts?.organizationId) {
114
+ query = query.where('organizationId', '=', opts.organizationId);
115
+ }
116
+ return query.execute();
117
+ }
118
+ /** A single delivery with its full attempt history, or null if unknown. */
119
+ async getDelivery(deliveryId) {
120
+ const delivery = await this.db
121
+ .selectFrom('webhookDelivery')
122
+ .selectAll()
123
+ .where('deliveryId', '=', deliveryId)
124
+ .executeTakeFirst();
125
+ if (!delivery) {
126
+ return null;
127
+ }
128
+ const attempts = await this.db
129
+ .selectFrom('webhookDeliveryAttempt')
130
+ .selectAll()
131
+ .where('deliveryId', '=', deliveryId)
132
+ .orderBy('attemptNumber', 'asc')
133
+ .execute();
134
+ return { delivery, attempts };
135
+ }
136
+ async createIndexSafe(builder) {
137
+ try {
138
+ await builder.execute();
139
+ }
140
+ catch (e) {
141
+ if (e?.code === 'ER_DUP_KEYNAME' || e?.errno === 1061)
142
+ return;
143
+ if (e?.code === '42P07')
144
+ return;
145
+ if (e?.message?.includes('already exists'))
146
+ return;
147
+ throw e;
148
+ }
149
+ }
150
+ }
@@ -1 +1 @@
1
- {"root":["../src/create-audited-kysely.ts","../src/index.ts","../src/kysely-ai-agent-run-service.ts","../src/kysely-ai-run-state-service.ts","../src/kysely-ai-storage-service.ts","../src/kysely-channel-store.ts","../src/kysely-credential-service.ts","../src/kysely-deployment-service.ts","../src/kysely-eventhub-store.ts","../src/kysely-json.ts","../src/kysely-secret-service.ts","../src/kysely-session-store.ts","../src/kysely-tables.ts","../src/kysely-workflow-mirror.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../src/serialize-plugin.ts","../bin/pikku-kysely-pure.ts"],"version":"5.9.3"}
1
+ {"root":["../src/create-audited-kysely.ts","../src/index.ts","../src/kysely-ai-agent-run-service.ts","../src/kysely-ai-run-state-service.ts","../src/kysely-ai-storage-service.ts","../src/kysely-audit-service.ts","../src/kysely-channel-store.ts","../src/kysely-credential-service.ts","../src/kysely-deployment-service.ts","../src/kysely-eventhub-store.ts","../src/kysely-json.ts","../src/kysely-scope-service.ts","../src/kysely-secret-service.ts","../src/kysely-session-store.ts","../src/kysely-tables.ts","../src/kysely-webhook-service.ts","../src/kysely-workflow-mirror.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../src/serialize-plugin.ts","../bin/pikku-kysely-pure.ts"],"version":"6.0.3"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.12.18",
3
+ "version": "0.13.1",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/src/index.js",
@@ -20,7 +20,7 @@
20
20
  "prepublishOnly": "yarn build"
21
21
  },
22
22
  "peerDependencies": {
23
- "@pikku/core": "^0.12.39"
23
+ "@pikku/core": "^0.12.64"
24
24
  },
25
25
  "dependencies": {
26
26
  "kysely": "^0.29.0"
@@ -31,7 +31,7 @@
31
31
  "kysely-codegen": "^0.20.0",
32
32
  "kysely-postgres-js": "^3.0.0",
33
33
  "postgres": "^3.4.8",
34
- "typescript": "^5.9"
34
+ "typescript": "^6.0.3"
35
35
  },
36
36
  "exports": {
37
37
  ".": "./dist/src/index.js"
package/run-tests.sh CHANGED
@@ -38,16 +38,16 @@ if [ ${#files[@]} -eq 0 ]; then
38
38
  fi
39
39
 
40
40
  # Construct the node command
41
- node_cmd="node --import tsx --test"
41
+ node_cmd=(node --import tsx --test)
42
42
 
43
43
  # Append options based on flags
44
44
  if [ "$watch_mode" = true ]; then
45
- node_cmd="$node_cmd --watch"
45
+ node_cmd+=(--watch)
46
46
  fi
47
47
 
48
48
  if [ "$coverage_mode" = true ]; then
49
- node_cmd="$node_cmd --test-coverage-include=\"src/**/*.{ts,js}\" --test-coverage-exclude=\"**/dist/**\" --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info"
49
+ node_cmd+=(--test-coverage-include="src/**/*.{ts,js}" --test-coverage-exclude="**/dist/**" --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info)
50
50
  fi
51
51
 
52
52
  # Execute the node command with the expanded list of files
53
- $node_cmd "${files[@]}"
53
+ "${node_cmd[@]}" "${files[@]}"
package/src/index.ts CHANGED
@@ -12,10 +12,13 @@ export type { KyselySecretServiceConfig } from './kysely-secret-service.js'
12
12
  export { KyselyCredentialService } from './kysely-credential-service.js'
13
13
  export type { KyselyCredentialServiceConfig } from './kysely-credential-service.js'
14
14
  export { KyselySessionStore } from './kysely-session-store.js'
15
+ export { KyselyScopeService } from './kysely-scope-service.js'
16
+ export { KyselyWebhookService } from './kysely-webhook-service.js'
15
17
  export {
16
18
  createAuditedKysely,
17
19
  type CreateAuditedKyselyOptions,
18
20
  } from './create-audited-kysely.js'
21
+ export { KyselyAuditService } from './kysely-audit-service.js'
19
22
 
20
23
  export {
21
24
  SerializePlugin,
@@ -0,0 +1,62 @@
1
+ import type { AuditEvent, AuditEventBatch, AuditService } from '@pikku/core'
2
+ import type { Kysely } from 'kysely'
3
+
4
+ const jsonOrNull = (v: unknown): string | null =>
5
+ v != null ? JSON.stringify(v) : null
6
+
7
+ // No global `crypto` is guaranteed across every runtime, and audit_id is the
8
+ // PK — any collision is dropped by ON CONFLICT DO NOTHING.
9
+ const fallbackId = (): string =>
10
+ `evt_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
11
+
12
+ /**
13
+ * Durable {@link AuditService} that persists AuditEvents to an `audit` table
14
+ * via Kysely — the companion sink to {@link createAuditedKysely}.
15
+ *
16
+ * The column mapping matches a platform audit-queue consumer, so a locally-run
17
+ * 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
19
+ * `metadata`). The `audit` table is an optional per-project migration (not in
20
+ * the generated schema), so rows are inserted untyped; all columns are TEXT on
21
+ * every engine and ON CONFLICT DO NOTHING keeps writes idempotent on retries.
22
+ */
23
+ export class KyselyAuditService implements AuditService {
24
+ constructor(private db: Kysely<any>) {}
25
+
26
+ async audit(event: AuditEvent): Promise<void> {
27
+ await this.write([event])
28
+ }
29
+
30
+ async write(batch: AuditEventBatch): Promise<void> {
31
+ if (!batch.length) return
32
+ const rows = batch.map((e) => {
33
+ const metadata = e.metadata as
34
+ | { tables?: unknown; changedColumns?: unknown }
35
+ | undefined
36
+ return {
37
+ audit_id: e.eventId ?? e.queryId ?? fallbackId(),
38
+ occurred_at: e.occurredAt ?? new Date().toISOString(),
39
+ type: e.type ?? 'unknown',
40
+ source: e.source ?? 'auto',
41
+ outcome: e.outcome ?? null,
42
+ function_id: e.functionId ?? null,
43
+ wire_type: e.wireType ?? null,
44
+ trace_id: e.traceId ?? null,
45
+ transaction_id: e.transactionId ?? null,
46
+ query_id: e.queryId ?? null,
47
+ actor_user_id: e.actor?.userId ?? null,
48
+ actor_org_id: e.actor?.orgId ?? null,
49
+ tables: jsonOrNull(metadata?.tables),
50
+ changed_cols: jsonOrNull(metadata?.changedColumns),
51
+ event: e.eventId ?? null,
52
+ old: null,
53
+ data: jsonOrNull(e.metadata),
54
+ }
55
+ })
56
+ await (this.db as any)
57
+ .insertInto('audit')
58
+ .values(rows)
59
+ .onConflict((oc: any) => oc.doNothing())
60
+ .execute()
61
+ }
62
+ }