@pikku/kysely 0.13.27 → 0.13.29

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,51 @@
1
+ ## 0.13.29
2
+
3
+ ### Patch Changes
4
+
5
+ - 5e93f30: Add `wireTriggerWebhookSource({ name, method?, route?, secret?, events, receive?, check?, setup?, teardown? })` in `#pikku/trigger`. Each source becomes a `POST /webhooks/<name>` route whose events are validated against their schemas and queued on `pikku-incoming-webhooks` through `IncomingWebhookService`; a generated worker runs the matching `wireTrigger({ name: '<source>:<event>' })` and the queue retries it on failure. `pikku webhooks status | setup | teardown --url --labelPrefix [--previous]` registers the routes with the provider and prints one JSON line per source.
6
+
7
+ `KyselyIncomingWebhookService` (with the `incoming-webhook` schema) records a receipt per event, drops a provider's redelivery of an event it already accepted, and keeps each dispatch's attempts and last error. `pikku dev` and `pikku serve` use it when a Kysely database is configured.
8
+
9
+ - Updated dependencies [5e93f30]
10
+ - Updated dependencies [5e93f30]
11
+ - @pikku/core@0.12.125
12
+
13
+ ## 0.13.28
14
+
15
+ ### Patch Changes
16
+
17
+ - 4a9dcd2: `admin:listUsers` now pages, counts and can carry roles.
18
+
19
+ `ListUsersInput` gains `offset` and `includeRoles`; `ListUsersOutput` gains `total`, and each `User` gains `roles` and `fields`. `total` is how many users match `search`, which is what a pager counts against — `users.length` never was, because it is capped by `limit`.
20
+
21
+ ```typescript
22
+ const { users, total } = await rpc.invoke('admin:listUsers', {
23
+ search: 'example.com',
24
+ limit: 50,
25
+ offset: 50,
26
+ includeRoles: true,
27
+ })
28
+ ```
29
+
30
+ Paging only means something over a stable order, so the query now sorts newest first rather than however the database felt like returning rows.
31
+
32
+ Synthetic principals — the platform credential owner, Fabric service users, scenario actors — are excluded by the query instead of dropped from the page afterwards. Filtering after the fact broke both halves of paging: `limit` had already counted the rows it then discarded, so a page came back short, and `offset` skipped synthetic rows as though they were people, so the same person could appear on two pages or on none.
33
+
34
+ `includeRoles` is refused without `admin:scopes:read`. `admin:users:list` says who may see the directory; it does not say who may see what each of those users can do.
35
+
36
+ `ScopeService` gains `listRolesForUsers(userIds)`, implemented in `@pikku/kysely`. It answers for every id asked for — an empty array for a user holding no roles, so a caller cannot read a missing key as "holds nothing" — and chunks its `in` list to stay inside the bound-parameter cap. A page of users used to cost one query per row, which on a database reached over the network is a round trip per row.
37
+
38
+ ```typescript
39
+ listRolesForUsers(userIds: string[]): Promise<Record<string, string[]>>
40
+ ```
41
+
42
+ Anything implementing `ScopeService` outside this repository has to add it.
43
+
44
+ - Updated dependencies [4a9dcd2]
45
+ - Updated dependencies [5ab24ad]
46
+ - Updated dependencies [42b7ac3]
47
+ - @pikku/core@0.12.120
48
+
1
49
  ## 0.13.27
2
50
 
3
51
  ### Patch Changes
@@ -14,6 +14,7 @@ export { KyselyScopeService } from './kysely-scope-service.js';
14
14
  export { KyselyFeatureFlagStore } from './kysely-feature-flag-store.js';
15
15
  export type { KyselyFeatureFlagStoreOptions } from './kysely-feature-flag-store.js';
16
16
  export { KyselyWebhookService } from './kysely-webhook-service.js';
17
+ export { KyselyIncomingWebhookService } from './kysely-incoming-webhook-service.js';
17
18
  export { createAuditedKysely, type CreateAuditedKyselyOptions, } from './create-audited-kysely.js';
18
19
  export { KyselyAuditService } from './kysely-audit-service.js';
19
20
  export { KyselyAnalyticsService } from './kysely-analytics-service.js';
@@ -23,7 +24,7 @@ export { SerializePlugin, BaseSerializePlugin, type Serializer, type Deserialize
23
24
  export { createCoercionPlugin, type ColumnKind, type CoercionMap, type CreateCoercionPluginOptions, } from './coercion-plugin.js';
24
25
  export { createClassificationPlugin, type CreateClassificationPluginOptions, } from './classification-plugin.js';
25
26
  export { ClassificationCrypto, DEFAULT_KEY_ID, isColumnEnvelope, parseColumnEnvelope, type ClassificationCryptoOptions, type ColumnEnvelope, type KEKResolver, type ResolvedKEK, } from './classification-crypto.js';
26
- export { agentSchema, analyticsSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, flagSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, type PikkuSchema, type RequiredTypes, type UnmetRequirement, type SchemaStatementFactory, } from './schema/index.js';
27
+ export { agentSchema, analyticsSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, flagSchema, incomingWebhookSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, type PikkuSchema, type RequiredTypes, type UnmetRequirement, type SchemaStatementFactory, } from './schema/index.js';
27
28
  export type { KyselyPikkuDB } from './kysely-tables.js';
28
29
  export type { WorkflowRunService } from '@pikku/core/workflow';
29
30
  export type { AgentRunService, AgentRunRow } from '@pikku/core/agent';
package/dist/src/index.js CHANGED
@@ -13,6 +13,7 @@ export { KyselySessionStore } from './kysely-session-store.js';
13
13
  export { KyselyScopeService } from './kysely-scope-service.js';
14
14
  export { KyselyFeatureFlagStore } from './kysely-feature-flag-store.js';
15
15
  export { KyselyWebhookService } from './kysely-webhook-service.js';
16
+ export { KyselyIncomingWebhookService } from './kysely-incoming-webhook-service.js';
16
17
  export { createAuditedKysely, } from './create-audited-kysely.js';
17
18
  export { KyselyAuditService } from './kysely-audit-service.js';
18
19
  export { KyselyAnalyticsService } from './kysely-analytics-service.js';
@@ -22,4 +23,4 @@ export { SerializePlugin, BaseSerializePlugin, } from './serialize-plugin.js';
22
23
  export { createCoercionPlugin, } from './coercion-plugin.js';
23
24
  export { createClassificationPlugin, } from './classification-plugin.js';
24
25
  export { ClassificationCrypto, DEFAULT_KEY_ID, isColumnEnvelope, parseColumnEnvelope, } from './classification-crypto.js';
25
- export { agentSchema, analyticsSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, flagSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, } from './schema/index.js';
26
+ export { agentSchema, analyticsSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, flagSchema, incomingWebhookSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, } from './schema/index.js';
@@ -0,0 +1,29 @@
1
+ import { IncomingWebhookService, type IncomingWebhookAttempt, type IncomingWebhookReceiptRecord } from '@pikku/core/services';
2
+ import type { QueueService } from '@pikku/core/queue';
3
+ import type { TriggerEvent, WebhookRequest } from '@pikku/core/trigger';
4
+ import type { Kysely } from 'kysely';
5
+ import type { KyselyPikkuDB } from './kysely-tables.js';
6
+ /**
7
+ * Durable {@link IncomingWebhookService}: records a `webhook_receipt` row per
8
+ * accepted event before queueing it, so a provider's redelivery of an event id
9
+ * it has already sent is dropped, and each trigger attempt rolls the receipt's
10
+ * status forward.
11
+ */
12
+ export declare class KyselyIncomingWebhookService extends IncomingWebhookService {
13
+ private db;
14
+ private initialized;
15
+ constructor(queueService: QueueService, db: Kysely<KyselyPikkuDB>, retries?: number);
16
+ init(): Promise<void>;
17
+ accept({ source, events, }: {
18
+ source: string;
19
+ request: WebhookRequest;
20
+ events: TriggerEvent[];
21
+ }): Promise<number>;
22
+ /** The new receipt's id, or undefined when this provider event id was already received. */
23
+ private record;
24
+ recordAttempt(receiptId: string, { error }: IncomingWebhookAttempt): Promise<void>;
25
+ listReceipts(opts?: {
26
+ source?: string;
27
+ limit?: number;
28
+ }): Promise<IncomingWebhookReceiptRecord[]>;
29
+ }
@@ -0,0 +1,94 @@
1
+ import { IncomingWebhookService, } from '@pikku/core/services';
2
+ import { requirePikkuSchema } from './schema/index.js';
3
+ import { incomingWebhookSchema } from './schema/incoming-webhook.schema.js';
4
+ /**
5
+ * Durable {@link IncomingWebhookService}: records a `webhook_receipt` row per
6
+ * accepted event before queueing it, so a provider's redelivery of an event id
7
+ * it has already sent is dropped, and each trigger attempt rolls the receipt's
8
+ * status forward.
9
+ */
10
+ export class KyselyIncomingWebhookService extends IncomingWebhookService {
11
+ db;
12
+ initialized = false;
13
+ constructor(queueService, db, retries) {
14
+ super(queueService, retries);
15
+ this.db = db;
16
+ }
17
+ async init() {
18
+ if (this.initialized)
19
+ return;
20
+ await requirePikkuSchema(this.db, incomingWebhookSchema);
21
+ this.initialized = true;
22
+ }
23
+ async accept({ source, events, }) {
24
+ let accepted = 0;
25
+ for (const event of events) {
26
+ const receiptId = await this.record(source, event);
27
+ if (!receiptId)
28
+ continue;
29
+ try {
30
+ await this.enqueue({ source, event, receiptId });
31
+ }
32
+ catch (error) {
33
+ // Without the receipt the provider's retry is accepted again rather
34
+ // than dropped as a duplicate of an event that was never queued.
35
+ await this.db
36
+ .deleteFrom('webhookReceipt')
37
+ .where('receiptId', '=', receiptId)
38
+ .execute();
39
+ throw error;
40
+ }
41
+ accepted++;
42
+ }
43
+ return accepted;
44
+ }
45
+ /** The new receipt's id, or undefined when this provider event id was already received. */
46
+ async record(source, event) {
47
+ const receiptId = globalThis.crypto.randomUUID();
48
+ const providerEventId = event.id ?? null;
49
+ try {
50
+ await this.db
51
+ .insertInto('webhookReceipt')
52
+ .values({ receiptId, source, event: event.name, providerEventId })
53
+ .execute();
54
+ return receiptId;
55
+ }
56
+ catch (error) {
57
+ if (providerEventId === null)
58
+ throw error;
59
+ const existing = await this.db
60
+ .selectFrom('webhookReceipt')
61
+ .select('receiptId')
62
+ .where('source', '=', source)
63
+ .where('event', '=', event.name)
64
+ .where('providerEventId', '=', providerEventId)
65
+ .executeTakeFirst();
66
+ if (existing)
67
+ return undefined;
68
+ throw error;
69
+ }
70
+ }
71
+ async recordAttempt(receiptId, { error }) {
72
+ await this.db
73
+ .updateTable('webhookReceipt')
74
+ .set((eb) => ({
75
+ attempts: eb('attempts', '+', 1),
76
+ status: error ? 'failed' : 'delivered',
77
+ lastError: error ?? null,
78
+ ...(error ? {} : { deliveredAt: new Date() }),
79
+ }))
80
+ .where('receiptId', '=', receiptId)
81
+ .execute();
82
+ }
83
+ async listReceipts(opts) {
84
+ let query = this.db
85
+ .selectFrom('webhookReceipt')
86
+ .selectAll()
87
+ .orderBy('createdAt', 'desc')
88
+ .limit(opts?.limit ?? 100);
89
+ if (opts?.source) {
90
+ query = query.where('source', '=', opts.source);
91
+ }
92
+ return query.execute();
93
+ }
94
+ }
@@ -61,6 +61,7 @@ export declare class KyselyScopeService implements ScopeService {
61
61
  addUserToRole(userId: string, role: string, grantedBy?: string): Promise<void>;
62
62
  removeUserFromRole(userId: string, role: string): Promise<void>;
63
63
  listUserRoles(userId: string): Promise<string[]>;
64
+ listRolesForUsers(userIds: string[]): Promise<Record<string, string[]>>;
64
65
  addScopeToUser(userId: string, scope: string, grantedBy?: string): Promise<void>;
65
66
  removeScopeFromUser(userId: string, scope: string): Promise<void>;
66
67
  listUserScopes(userId: string): Promise<string[]>;
@@ -236,6 +236,35 @@ export class KyselyScopeService {
236
236
  .execute();
237
237
  return rows.map((row) => row.role);
238
238
  }
239
+ async listRolesForUsers(userIds) {
240
+ // Seeded with every id asked for, so a user holding no roles is an empty
241
+ // array rather than a missing key.
242
+ const byUser = {};
243
+ for (const userId of userIds) {
244
+ byUser[userId] = [];
245
+ }
246
+ const distinct = Object.keys(byUser);
247
+ if (distinct.length === 0) {
248
+ return byUser;
249
+ }
250
+ // Chunked because an `in` list is one bound parameter per id, and every
251
+ // engine caps those — SQLite at 999 on older builds, Postgres at 65535 for
252
+ // the whole statement. A caller asking for a page of users stays well
253
+ // inside one chunk; one asking for thousands still gets an answer instead
254
+ // of a driver error.
255
+ for (let i = 0; i < distinct.length; i += 500) {
256
+ const chunk = distinct.slice(i, i + 500);
257
+ const rows = await this.db
258
+ .selectFrom('pikkuUserRole')
259
+ .select(['userId', 'role'])
260
+ .where('userId', 'in', chunk)
261
+ .execute();
262
+ for (const row of rows) {
263
+ byUser[row.userId].push(row.role);
264
+ }
265
+ }
266
+ return byUser;
267
+ }
239
268
  async addScopeToUser(userId, scope, grantedBy) {
240
269
  await this.db
241
270
  .insertInto('pikkuUserScope')
@@ -282,6 +282,17 @@ export interface WebhookDeliveryAttemptTable {
282
282
  error: string | null;
283
283
  createdAt: Generated<Date>;
284
284
  }
285
+ export interface WebhookReceiptTable {
286
+ receiptId: string;
287
+ source: string;
288
+ event: string;
289
+ providerEventId: string | null;
290
+ status: Generated<'pending' | 'delivered' | 'failed'>;
291
+ attempts: Generated<number>;
292
+ lastError: string | null;
293
+ createdAt: Generated<Date>;
294
+ deliveredAt: Date | null;
295
+ }
285
296
  /**
286
297
  * One virtual-user run. The JSON columns (`goals`, `memory`, `findings`,
287
298
  * `tally`) are text the store serialises itself, so the row is byte-identical
@@ -433,6 +444,7 @@ export interface KyselyPikkuDB {
433
444
  pikkuUserSessions: UserSessionsTable;
434
445
  webhookDelivery: WebhookDeliveryTable;
435
446
  webhookDeliveryAttempt: WebhookDeliveryAttemptTable;
447
+ webhookReceipt: WebhookReceiptTable;
436
448
  virtualUserRun: VirtualUserRunTable;
437
449
  virtualUserRunStep: VirtualUserRunStepTable;
438
450
  virtualUserSchedule: VirtualUserScheduleTable;
@@ -0,0 +1,3 @@
1
+ import type { PikkuSchema } from './pikku-schema.types.js';
2
+ /** One row per event a webhook source accepted, keyed for provider de-duplication. */
3
+ export declare const incomingWebhookSchema: PikkuSchema;
@@ -0,0 +1,28 @@
1
+ import { sql } from 'kysely';
2
+ /** One row per event a webhook source accepted, keyed for provider de-duplication. */
3
+ export const incomingWebhookSchema = {
4
+ name: 'incoming-webhook',
5
+ ownedBy: ['incomingWebhookService'],
6
+ statements: [
7
+ (db) => db.schema
8
+ .createTable('webhookReceipt')
9
+ .addColumn('receiptId', 'text', (col) => col.primaryKey())
10
+ .addColumn('source', 'text', (col) => col.notNull())
11
+ .addColumn('event', 'text', (col) => col.notNull())
12
+ .addColumn('providerEventId', 'text')
13
+ .addColumn('status', 'text', (col) => col.defaultTo('pending').notNull())
14
+ .addColumn('attempts', 'integer', (col) => col.defaultTo(0).notNull())
15
+ .addColumn('lastError', 'text')
16
+ .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
17
+ .addColumn('deliveredAt', 'timestamp'),
18
+ (db) => db.schema
19
+ .createIndex('idx_webhook_receipt_provider_event')
20
+ .unique()
21
+ .on('webhookReceipt')
22
+ .columns(['source', 'event', 'providerEventId']),
23
+ (db) => db.schema
24
+ .createIndex('idx_webhook_receipt_source_created')
25
+ .on('webhookReceipt')
26
+ .columns(['source', 'createdAt']),
27
+ ],
28
+ };
@@ -24,6 +24,7 @@ export { channelSchema } from './channel.schema.js';
24
24
  export { credentialSchema } from './credential.schema.js';
25
25
  export { deploymentSchema } from './deployment.schema.js';
26
26
  export { flagSchema } from './flag.schema.js';
27
+ export { incomingWebhookSchema } from './incoming-webhook.schema.js';
27
28
  export { scopeSchema } from './scope.schema.js';
28
29
  export { secretSchema } from './secret.schema.js';
29
30
  export { sessionSchema } from './session.schema.js';
@@ -8,6 +8,7 @@ import { credentialSchema } from './credential.schema.js';
8
8
  import { deploymentSchema } from './deployment.schema.js';
9
9
  import { scopeSchema } from './scope.schema.js';
10
10
  import { flagSchema } from './flag.schema.js';
11
+ import { incomingWebhookSchema } from './incoming-webhook.schema.js';
11
12
  import { secretSchema } from './secret.schema.js';
12
13
  import { sessionSchema } from './session.schema.js';
13
14
  import { webhookSchema } from './webhook.schema.js';
@@ -36,6 +37,7 @@ export const pikkuSchemas = [
36
37
  credentialSchema,
37
38
  deploymentSchema,
38
39
  webhookSchema,
40
+ incomingWebhookSchema,
39
41
  workflowSchema,
40
42
  agentSchema,
41
43
  scopeSchema,
@@ -52,6 +54,7 @@ export { channelSchema } from './channel.schema.js';
52
54
  export { credentialSchema } from './credential.schema.js';
53
55
  export { deploymentSchema } from './deployment.schema.js';
54
56
  export { flagSchema } from './flag.schema.js';
57
+ export { incomingWebhookSchema } from './incoming-webhook.schema.js';
55
58
  export { scopeSchema } from './scope.schema.js';
56
59
  export { secretSchema } from './secret.schema.js';
57
60
  export { sessionSchema } from './session.schema.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.13.27",
3
+ "version": "0.13.29",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -26,7 +26,7 @@
26
26
  "prepublishOnly": "bun run build"
27
27
  },
28
28
  "peerDependencies": {
29
- "@pikku/core": "^0.12.111"
29
+ "@pikku/core": "^0.12.125"
30
30
  },
31
31
  "dependencies": {
32
32
  "kysely": "^0.29.5"