@pikku/kysely 0.13.0 → 0.13.2

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
+ }
@@ -308,11 +308,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
308
308
  if (latestHistory) {
309
309
  await this.db
310
310
  .updateTable('workflowStepHistory')
311
- .set({
312
- status: 'succeeded',
313
- result: resultJson,
314
- succeededAt: new Date(),
315
- })
311
+ .set({ status: 'succeeded', result: resultJson, succeededAt: new Date() })
316
312
  .where('historyId', '=', latestHistory.historyId)
317
313
  .execute();
318
314
  }
@@ -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-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-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":"6.0.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.13.0",
3
+ "version": "0.13.2",
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.44"
23
+ "@pikku/core": "^0.12.66"
24
24
  },
25
25
  "dependencies": {
26
26
  "kysely": "^0.29.0"
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,6 +12,8 @@ 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,
@@ -5,19 +5,36 @@ import type {
5
5
  AgentRunService,
6
6
  } from '@pikku/core/ai-agent'
7
7
  import type { Kysely } from 'kysely'
8
+ import { sql } from 'kysely'
8
9
  import type { KyselyPikkuDB } from './kysely-tables.js'
9
10
  import { parseJson } from './kysely-json.js'
10
11
 
12
+ // Owner ids routinely contain `_` (e.g. `user_123`), which LIKE treats as a
13
+ // single-character wildcard — so a raw prefix would match a foreign owner.
14
+ const escapeLikePattern = (value: string) =>
15
+ value.replace(/[\\%_]/g, (char) => `\\${char}`)
16
+
11
17
  export class KyselyAgentRunService implements AgentRunService {
12
18
  constructor(private db: Kysely<KyselyPikkuDB>) {}
13
19
 
14
20
  async listThreads(options?: {
15
21
  agentName?: string
16
22
  resourceId?: string
23
+ owners?: string[]
17
24
  limit?: number
18
25
  offset?: number
19
26
  }): Promise<AIThread[]> {
20
- const { agentName, resourceId, limit = 50, offset = 0 } = options ?? {}
27
+ const {
28
+ agentName,
29
+ resourceId,
30
+ owners,
31
+ limit = 50,
32
+ offset = 0,
33
+ } = options ?? {}
34
+
35
+ // An owners constraint is an authorization boundary, so an empty list must
36
+ // match nothing rather than degrade to "no filter".
37
+ if (owners && owners.length === 0) return []
21
38
 
22
39
  let query = this.db
23
40
  .selectFrom('aiThreads as t')
@@ -46,6 +63,21 @@ export class KyselyAgentRunService implements AgentRunService {
46
63
  query = query.where('t.resourceId', '=', resourceId)
47
64
  }
48
65
 
66
+ if (owners) {
67
+ query = query.where((eb) =>
68
+ eb.or(
69
+ owners.flatMap((owner) => [
70
+ eb('t.resourceId', '=', owner),
71
+ eb(
72
+ 't.resourceId',
73
+ 'like',
74
+ sql<string>`${`${escapeLikePattern(owner)}:%`} escape '\\'`
75
+ ),
76
+ ])
77
+ )
78
+ )
79
+ }
80
+
49
81
  const result = await query
50
82
  .orderBy('t.updatedAt', 'desc')
51
83
  .limit(limit)