@pikku/kysely 0.12.15 → 0.12.17

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,87 @@
1
+ ## 0.12.17
2
+
3
+ ### Patch Changes
4
+
5
+ - 92cd5b1: feat(workflow): workflow-owned step retries + stable invocationId
6
+
7
+ The workflow — not the queue — now owns step retry policy, and each step
8
+ invocation gets a stable idempotency key.
9
+ - **Default `retries: 5` with exponential backoff.** A step with no `retries`
10
+ previously inherited the queue's bare default (e.g. pg-boss `retry_limit 2`,
11
+ no backoff) so retries fired instantly and couldn't outlast a transient
12
+ outage. Retries now default to 5 with backoff, resolved at the workflow layer.
13
+ - **`retries: 0` is honored.** Dispatch previously passed `undefined` options
14
+ for `retries: 0`, letting the queue re-run a non-idempotent step up to its own
15
+ default. The resolved policy now always sets `attempts` (`retries: 0` →
16
+ `attempts: 1`), so the queue never second-guesses the workflow. The persisted
17
+ step retries and the dispatched `attempts` are resolved together so
18
+ "retries exhausted" and "no more redeliveries" are the same event.
19
+ - **`workflowStep.invocationId`** — a deterministic, dependency-free
20
+ `uuidv5(runId:stepName)` handed to every step. Unlike `stepId` (minted per
21
+ attempt), it is identical across retries, so a step can dedupe on it
22
+ (`ON CONFLICT (invocationId)`, Stripe idempotency keys, etc.).
23
+ - **queue-bullmq**: `mapPikkuJobToBull` now maps `backoff` (previously dropped,
24
+ so a step's backoff silently never applied on Redis), and `registerQueues`
25
+ throws a clear error when no logger is available (matching queue-pg-boss).
26
+ - **Dispatch failures are recoverable, not fatal.** A step is now marked
27
+ `scheduled` only _after_ it is successfully handed to its transport (queue or
28
+ scheduler) — a failed hand-off leaves it `pending` so a replay re-dispatches
29
+ it, instead of stranding it in `scheduled` (replay would pause forever on a
30
+ job that was never enqueued). A transport outage (e.g. pg-boss momentarily
31
+ down) is surfaced as a new `WorkflowDispatchException`, which the orchestrator
32
+ treats as transient: the run is left running and the orchestrator job is
33
+ rethrown for redelivery (it replays idempotently from the snapshot) rather
34
+ than the whole run being marked `failed`. The orchestrator job now also
35
+ carries its own retry policy, so this holds even when the orchestrator queue
36
+ is configured `retry_limit 0`. A genuine step error still fails the run.
37
+ - **Same step name can be invoked multiple times in one run.** Step rows are now
38
+ keyed per _invocation_: the Nth reach of a step name in a replay resolves to a
39
+ physical key (`name` for the first, `name#N` for repeats), so a literal
40
+ duplicate name no longer clobbers the earlier step's state. The first reach
41
+ keeps the bare name, so existing rows, graph-node matching and `invocationId`s
42
+ are unchanged. Ordinals are derived deterministically from DSL execution order
43
+ and reset each replay.
44
+ - **Step provenance (`fromStepName`) + graph cycles.** Every step now records
45
+ the predecessor it was scheduled from (`fromStepName`; entry steps have none),
46
+ persisted on the step row across all stores (in-memory, kysely, redis,
47
+ mongodb, cloudflare DO) and carried in the queued payload. The DSL wire
48
+ exposes the derived `fromInvocationId` (`uuidv5(runId:fromStepName)`) so
49
+ consumers get the stable predecessor key without a second persisted id —
50
+ `fromStepName` is the source of truth (it is replay-deterministic; `stepId`,
51
+ minted per row, is not). This makes the walked path reconstructable even when
52
+ a node is reached more than once: in `a → b → a → c` the second `a` is a
53
+ distinct ordinal instance (`a#1`) whose `fromStepName` is `b`.
54
+ The graph runner now supports **cycles**: a forward edge into an
55
+ already-started node still collapses to a single run (joins/diamonds are
56
+ unchanged), but a _back-edge_ — one whose target can reach its source — fires
57
+ a fresh ordinal instance, so a node can loop back to itself. Termination is
58
+ the graph's responsibility (branch routing must converge); the engine enforces
59
+ no visit cap.
60
+
61
+ - Updated dependencies [92cd5b1]
62
+ - @pikku/core@0.12.38
63
+
64
+ ## 0.12.16
65
+
66
+ ### Patch Changes
67
+
68
+ - 6565b97: Fix `KyselyWorkflowRunService.getRunSteps`: build the `runningAt` /
69
+ `succeededAt` / `failedAt` correlated subqueries through the query builder
70
+ instead of raw `sql` fragments, so the active schema (`withSchema(...)`)
71
+ qualifies `workflow_step_history`. The raw fragments hardcoded an unqualified
72
+ table name and failed with `relation "workflow_step_history" does not exist`
73
+ against a connection whose `search_path` did not include the schema.
74
+ - 34f254e: Bump the `kysely` dependency range to `^0.29.0` so it dedupes onto a single
75
+ copy alongside Better Auth (which bundles kysely 0.29.x), avoiding two
76
+ incompatible `Kysely` classes (the `#private` brand mismatch) when both pikku's
77
+ adapters and Better Auth share a database connection.
78
+
79
+ kysely 0.29 is ESM-only, which the unmaintained `kysely-plugin-serialize`
80
+ (no `exports` map, CommonJS build) cannot import. Its `SerializePlugin` is now
81
+ maintained directly in `@pikku/kysely` and re-exported, and the external
82
+ dependency is dropped from `@pikku/kysely`, `@pikku/kysely-sqlite`, and
83
+ `@pikku/cloudflare`.
84
+
1
85
  ## 0.12.15
2
86
 
3
87
  ### Patch Changes
@@ -13,6 +13,7 @@ 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
15
  export { createAuditedKysely, type CreateAuditedKyselyOptions, } from './create-audited-kysely.js';
16
+ export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, defaultSerializer, defaultDeserializer, maybeJson, skipTransform, dateRegex, type Serializer, type Deserializer, type SerializePluginOptions, } from './serialize-plugin.js';
16
17
  export type { KyselyPikkuDB } from './kysely-tables.js';
17
18
  export type { WorkflowRunService } from '@pikku/core/workflow';
18
19
  export type { AgentRunService, AgentRunRow } from '@pikku/core/ai-agent';
package/dist/src/index.js CHANGED
@@ -11,3 +11,4 @@ export { KyselySecretService } from './kysely-secret-service.js';
11
11
  export { KyselyCredentialService } from './kysely-credential-service.js';
12
12
  export { KyselySessionStore } from './kysely-session-store.js';
13
13
  export { createAuditedKysely, } from './create-audited-kysely.js';
14
+ export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, defaultSerializer, defaultDeserializer, maybeJson, skipTransform, dateRegex, } from './serialize-plugin.js';
@@ -24,6 +24,6 @@ export declare class KyselySecretService implements SecretService {
24
24
  hasSecret(key: string): Promise<boolean>;
25
25
  setSecret(key: string, value: unknown): Promise<void>;
26
26
  deleteSecret(key: string): Promise<void>;
27
- getSecrets(keys: string[]): Promise<Record<string, unknown>>;
27
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<T>;
28
28
  rotateKEK(): Promise<number>;
29
29
  }
@@ -122,8 +122,15 @@ export class KyselySecretService {
122
122
  const kek = this.getKEK(row.keyVersion);
123
123
  out[row.key] = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
124
124
  }
125
- catch {
126
- // skip secrets that fail to decrypt
125
+ catch (cause) {
126
+ // A stored secret that fails to decrypt is never expected — it means the
127
+ // KEK does not match what the value was wrapped under (mismatched
128
+ // PIKKU_SECRET_KEK, or a missing previousKey for an older key_version).
129
+ // Swallowing it produces an undefined secret and an opaque downstream
130
+ // failure (e.g. an auth "server configuration" 500). Fail loud, naming
131
+ // the key and key_version so the misconfiguration is diagnosable.
132
+ throw new Error(`Failed to decrypt secret "${row.key}" (key_version ${row.keyVersion}): ` +
133
+ `the configured KEK does not match the key it was wrapped under`, { cause });
127
134
  }
128
135
  }
129
136
  return out;
@@ -43,6 +43,7 @@ export interface WorkflowStepTable {
43
43
  branchTaken: string | null;
44
44
  retries: number | null;
45
45
  retryDelay: string | null;
46
+ fromStepName: string | null;
46
47
  createdAt: Generated<Date>;
47
48
  updatedAt: Generated<Date>;
48
49
  }
@@ -1,4 +1,3 @@
1
- import { sql } from 'kysely';
2
1
  import { parseJson } from './kysely-json.js';
3
2
  export class KyselyWorkflowRunService {
4
3
  db;
@@ -83,9 +82,26 @@ export class KyselyWorkflowRunService {
83
82
  .select(eb.fn.countAll().as('cnt'))
84
83
  .whereRef('workflowStepHistory.workflowStepId', '=', 's.workflowStepId')
85
84
  .as('attemptCount'))
86
- .select(sql `(SELECT MAX(h.running_at) FROM workflow_step_history h WHERE h.workflow_step_id = s.workflow_step_id)`.as('runningAt'))
87
- .select(sql `(SELECT MAX(h.succeeded_at) FROM workflow_step_history h WHERE h.workflow_step_id = s.workflow_step_id)`.as('succeededAt'))
88
- .select(sql `(SELECT MAX(h.failed_at) FROM workflow_step_history h WHERE h.workflow_step_id = s.workflow_step_id)`.as('failedAt'))
85
+ // Build these correlated subqueries through the query builder rather
86
+ // than a raw `sql` fragment so the active schema (e.g.
87
+ // `withSchema('app')`) is applied to `workflow_step_history`. A raw
88
+ // fragment hardcodes an unqualified table name and dies against a
89
+ // connection whose search_path does not include the schema.
90
+ .select((eb) => eb
91
+ .selectFrom('workflowStepHistory')
92
+ .select((eb2) => eb2.fn.max('workflowStepHistory.runningAt').as('m'))
93
+ .whereRef('workflowStepHistory.workflowStepId', '=', 's.workflowStepId')
94
+ .as('runningAt'))
95
+ .select((eb) => eb
96
+ .selectFrom('workflowStepHistory')
97
+ .select((eb2) => eb2.fn.max('workflowStepHistory.succeededAt').as('m'))
98
+ .whereRef('workflowStepHistory.workflowStepId', '=', 's.workflowStepId')
99
+ .as('succeededAt'))
100
+ .select((eb) => eb
101
+ .selectFrom('workflowStepHistory')
102
+ .select((eb2) => eb2.fn.max('workflowStepHistory.failedAt').as('m'))
103
+ .whereRef('workflowStepHistory.workflowStepId', '=', 's.workflowStepId')
104
+ .as('failedAt'))
89
105
  .where('s.workflowRunId', '=', runId)
90
106
  .orderBy('s.createdAt', 'asc')
91
107
  .execute();
@@ -1,5 +1,5 @@
1
1
  import type { SerializedError } from '@pikku/core';
2
- import { PikkuWorkflowService, type WorkflowPlannedStep, type WorkflowRun, type WorkflowRunWire, type StepState, type WorkflowStatus, type WorkflowVersionStatus } from '@pikku/core/workflow';
2
+ import { PikkuWorkflowService, type WorkflowPlannedStep, type WorkflowRun, type WorkflowRunWire, type StepState, type StepStatus, type WorkflowStatus, type WorkflowVersionStatus } from '@pikku/core/workflow';
3
3
  import type { Kysely } from 'kysely';
4
4
  import type { KyselyPikkuDB } from './kysely-tables.js';
5
5
  export declare class KyselyWorkflowService extends PikkuWorkflowService {
@@ -17,7 +17,7 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
17
17
  protected insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: {
18
18
  retries?: number;
19
19
  retryDelay?: string | number;
20
- }): Promise<StepState>;
20
+ }, fromStepName?: string): Promise<StepState>;
21
21
  getStepState(runId: string, stepName: string): Promise<StepState>;
22
22
  getRunHistory(runId: string): Promise<Array<StepState & {
23
23
  stepName: string;
@@ -38,6 +38,11 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
38
38
  branchKeys: Record<string, string>;
39
39
  }>;
40
40
  getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
41
+ getStepInstances(runId: string): Promise<Array<{
42
+ stepName: string;
43
+ status: StepStatus;
44
+ fromStepName?: string;
45
+ }>>;
41
46
  getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
42
47
  protected setBranchTakenImpl(stepId: string, branchKey: string): Promise<void>;
43
48
  protected updateRunStateImpl(runId: string, name: string, value: unknown): Promise<void>;
@@ -55,6 +55,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
55
55
  .addColumn('branch_taken', 'text')
56
56
  .addColumn('retries', 'integer')
57
57
  .addColumn('retry_delay', 'text')
58
+ .addColumn('from_step_name', 'text')
58
59
  .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
59
60
  .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
60
61
  .addUniqueConstraint('workflow_step_run_name_unique', [
@@ -62,6 +63,14 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
62
63
  'step_name',
63
64
  ])
64
65
  .execute();
66
+ // Additive: backfill the column on tables created before from_step_name.
67
+ await this.db.schema
68
+ .alterTable('workflow_step')
69
+ .addColumn('from_step_name', 'text')
70
+ .execute()
71
+ .catch(() => {
72
+ // Column already exists (fresh tables include it above) — nothing to do.
73
+ });
65
74
  await this.db.schema
66
75
  .createTable('workflow_step_history')
67
76
  .ifNotExists()
@@ -132,7 +141,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
132
141
  .where('workflowRunId', '=', id)
133
142
  .execute();
134
143
  }
135
- async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions) {
144
+ async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName) {
136
145
  const stepId = crypto.randomUUID();
137
146
  const now = new Date();
138
147
  await this.db
@@ -146,6 +155,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
146
155
  status: 'pending',
147
156
  retries: stepOptions?.retries ?? null,
148
157
  retryDelay: stepOptions?.retryDelay?.toString() ?? null,
158
+ fromStepName: fromStepName ?? null,
149
159
  createdAt: now,
150
160
  updatedAt: now,
151
161
  })
@@ -159,6 +169,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
159
169
  attemptCount: 1,
160
170
  retries: stepOptions?.retries,
161
171
  retryDelay: stepOptions?.retryDelay?.toString(),
172
+ fromStepName,
162
173
  createdAt: now,
163
174
  updatedAt: now,
164
175
  };
@@ -173,6 +184,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
173
184
  's.error',
174
185
  's.retries',
175
186
  's.retryDelay',
187
+ 's.fromStepName',
176
188
  's.createdAt',
177
189
  's.updatedAt',
178
190
  ])
@@ -195,6 +207,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
195
207
  attemptCount: Number(row.attemptCount),
196
208
  retries: row.retries != null ? Number(row.retries) : undefined,
197
209
  retryDelay: row.retryDelay ?? undefined,
210
+ fromStepName: row.fromStepName ?? undefined,
198
211
  createdAt: new Date(row.createdAt),
199
212
  updatedAt: new Date(row.updatedAt),
200
213
  };
@@ -348,6 +361,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
348
361
  's.error',
349
362
  's.retries',
350
363
  's.retryDelay',
364
+ 's.fromStepName',
351
365
  's.createdAt',
352
366
  's.updatedAt',
353
367
  ])
@@ -366,6 +380,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
366
380
  attemptCount: Number(row.attemptCount),
367
381
  retries: row.retries != null ? Number(row.retries) : undefined,
368
382
  retryDelay: row.retryDelay ?? undefined,
383
+ fromStepName: row.fromStepName ?? undefined,
369
384
  createdAt: new Date(row.createdAt),
370
385
  updatedAt: new Date(row.updatedAt),
371
386
  };
@@ -420,6 +435,18 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
420
435
  const existingStepNames = new Set(result.map((r) => r.stepName));
421
436
  return nodeIds.filter((id) => !existingStepNames.has(id));
422
437
  }
438
+ async getStepInstances(runId) {
439
+ const rows = await this.db
440
+ .selectFrom('workflowStep')
441
+ .select(['stepName', 'status', 'fromStepName'])
442
+ .where('workflowRunId', '=', runId)
443
+ .execute();
444
+ return rows.map((r) => ({
445
+ stepName: r.stepName,
446
+ status: r.status,
447
+ fromStepName: r.fromStepName ?? undefined,
448
+ }));
449
+ }
423
450
  async getNodeResults(runId, nodeIds) {
424
451
  if (nodeIds.length === 0)
425
452
  return {};
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Serialize / deserialize plugin for Kysely.
3
+ *
4
+ * Adapted from `kysely-plugin-serialize` (MIT, by subframe7536 —
5
+ * https://github.com/subframe7536/kysely-plugin-serialize), which is
6
+ * unmaintained and ships no `exports` map, so modern Node16/ESM resolution
7
+ * falls back to its CommonJS build and cannot import ESM-only kysely (>= 0.29).
8
+ * Maintained here directly as part of @pikku/kysely so we publish it ourselves.
9
+ *
10
+ * Serializes object/array/Date/boolean parameters to strings on the way into
11
+ * SQLite-style dialects, and deserializes them back on the way out.
12
+ */
13
+ import { type KyselyPlugin, type PluginTransformQueryArgs, type PluginTransformResultArgs, type QueryResult, type RootOperationNode, type UnknownRow } from 'kysely';
14
+ export type Serializer = (parameter: unknown) => unknown;
15
+ export type Deserializer = (parameter: unknown) => unknown;
16
+ export declare const defaultSerializer: Serializer;
17
+ export declare const dateRegex: RegExp;
18
+ export declare const defaultDeserializer: Deserializer;
19
+ /**
20
+ * Checks if a given string parameter is a JSON-like string.
21
+ *
22
+ * Determines whether the input starts and ends with curly braces `{}` or
23
+ * square brackets `[]`, the typical indicators of JSON objects and arrays.
24
+ */
25
+ export declare function maybeJson(parameter: string): boolean;
26
+ /**
27
+ * Determines whether a given parameter should be skipped during transformation.
28
+ *
29
+ * Skipped when it is `undefined`, `null`, a `bigint`, a `number`, or an object
30
+ * that exposes a `buffer` property (e.g. a typed array / Buffer).
31
+ */
32
+ export declare function skipTransform(parameter: unknown): boolean;
33
+ export declare class BaseSerializePlugin implements KyselyPlugin {
34
+ private readonly transformer;
35
+ private readonly deserializer;
36
+ private readonly skipNodeSet?;
37
+ private readonly ctx?;
38
+ /**
39
+ * Base class for {@link SerializePlugin}, without default options.
40
+ */
41
+ constructor(serializer: Serializer, deserializer: Deserializer, skipNodeKind: Array<RootOperationNode['kind']>);
42
+ transformQuery({ node, queryId, }: PluginTransformQueryArgs): RootOperationNode;
43
+ transformResult({ result, queryId, }: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>>;
44
+ private parseRows;
45
+ }
46
+ export interface SerializePluginOptions {
47
+ /**
48
+ * serialize params
49
+ */
50
+ serializer?: Serializer;
51
+ /**
52
+ * deserialize params
53
+ */
54
+ deserializer?: Deserializer;
55
+ /**
56
+ * node kind to skip transform
57
+ */
58
+ skipNodeKind?: Array<RootOperationNode['kind']>;
59
+ }
60
+ export declare class SerializePlugin extends BaseSerializePlugin {
61
+ /**
62
+ * _**THIS PLUGIN SHOULD BE PLACED AT THE END OF PLUGINS ARRAY !!!**_
63
+ *
64
+ * reference from https://github.com/koskimas/kysely/pull/138
65
+ *
66
+ * Serializes object/array/Date/boolean column values to strings for
67
+ * SQLite-style dialects and deserializes them on the way back out. Pass a
68
+ * custom `serializer`/`deserializer` to override the default behaviour.
69
+ */
70
+ constructor(options?: SerializePluginOptions);
71
+ }
72
+ /**
73
+ * @deprecated use {@link SerializePlugin} instead
74
+ */
75
+ export declare const SqliteSerializePlugin: typeof SerializePlugin;
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Serialize / deserialize plugin for Kysely.
3
+ *
4
+ * Adapted from `kysely-plugin-serialize` (MIT, by subframe7536 —
5
+ * https://github.com/subframe7536/kysely-plugin-serialize), which is
6
+ * unmaintained and ships no `exports` map, so modern Node16/ESM resolution
7
+ * falls back to its CommonJS build and cannot import ESM-only kysely (>= 0.29).
8
+ * Maintained here directly as part of @pikku/kysely so we publish it ourselves.
9
+ *
10
+ * Serializes object/array/Date/boolean parameters to strings on the way into
11
+ * SQLite-style dialects, and deserializes them back on the way out.
12
+ */
13
+ import { OperationNodeTransformer, } from 'kysely';
14
+ class SerializeParametersTransformer extends OperationNodeTransformer {
15
+ serializer;
16
+ constructor(serializer) {
17
+ super();
18
+ this.serializer = serializer;
19
+ }
20
+ transformPrimitiveValueList(node) {
21
+ return {
22
+ ...node,
23
+ values: node.values.map(this.serializer),
24
+ };
25
+ }
26
+ // https://www.npmjs.com/package/zodsql
27
+ transformColumnUpdate(node) {
28
+ const { value: valueNode } = node;
29
+ if (valueNode.kind !== 'ValueNode') {
30
+ return super.transformColumnUpdate(node);
31
+ }
32
+ const { value, ...item } = valueNode;
33
+ const serializedValue = this.serializer(value);
34
+ if (value === serializedValue) {
35
+ return super.transformColumnUpdate(node);
36
+ }
37
+ const serializedNode = { ...item, value: serializedValue };
38
+ return super.transformColumnUpdate({ ...node, value: serializedNode });
39
+ }
40
+ transformValue(node) {
41
+ return {
42
+ ...node,
43
+ value: this.serializer(node.value),
44
+ };
45
+ }
46
+ }
47
+ export const defaultSerializer = (parameter) => {
48
+ if (skipTransform(parameter) || typeof parameter === 'string') {
49
+ return parameter;
50
+ }
51
+ else if (typeof parameter === 'boolean') {
52
+ return '' + parameter;
53
+ }
54
+ else if (parameter instanceof Date) {
55
+ return parameter.toISOString();
56
+ }
57
+ else {
58
+ try {
59
+ return JSON.stringify(parameter);
60
+ }
61
+ catch {
62
+ return parameter;
63
+ }
64
+ }
65
+ };
66
+ export const dateRegex = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/;
67
+ export const defaultDeserializer = (parameter) => {
68
+ if (skipTransform(parameter)) {
69
+ return parameter;
70
+ }
71
+ if (typeof parameter === 'string') {
72
+ if (parameter === 'true') {
73
+ return true;
74
+ }
75
+ else if (parameter === 'false') {
76
+ return false;
77
+ }
78
+ else if (dateRegex.test(parameter)) {
79
+ return new Date(parameter);
80
+ }
81
+ else if (maybeJson(parameter)) {
82
+ try {
83
+ return JSON.parse(parameter);
84
+ }
85
+ catch {
86
+ // not valid JSON — fall through and return the raw string
87
+ }
88
+ }
89
+ return parameter;
90
+ }
91
+ return parameter;
92
+ };
93
+ /**
94
+ * Checks if a given string parameter is a JSON-like string.
95
+ *
96
+ * Determines whether the input starts and ends with curly braces `{}` or
97
+ * square brackets `[]`, the typical indicators of JSON objects and arrays.
98
+ */
99
+ export function maybeJson(parameter) {
100
+ return ((parameter.startsWith('{') && parameter.endsWith('}')) ||
101
+ (parameter.startsWith('[') && parameter.endsWith(']')));
102
+ }
103
+ /**
104
+ * Determines whether a given parameter should be skipped during transformation.
105
+ *
106
+ * Skipped when it is `undefined`, `null`, a `bigint`, a `number`, or an object
107
+ * that exposes a `buffer` property (e.g. a typed array / Buffer).
108
+ */
109
+ export function skipTransform(parameter) {
110
+ return (parameter === undefined ||
111
+ parameter === null ||
112
+ typeof parameter === 'bigint' ||
113
+ typeof parameter === 'number' ||
114
+ (typeof parameter === 'object' && 'buffer' in parameter));
115
+ }
116
+ export class BaseSerializePlugin {
117
+ transformer;
118
+ deserializer;
119
+ skipNodeSet;
120
+ ctx;
121
+ /**
122
+ * Base class for {@link SerializePlugin}, without default options.
123
+ */
124
+ constructor(serializer, deserializer, skipNodeKind) {
125
+ this.transformer = new SerializeParametersTransformer(serializer);
126
+ this.deserializer = deserializer;
127
+ if (skipNodeKind.length) {
128
+ this.skipNodeSet = new Set(skipNodeKind);
129
+ this.ctx = new WeakSet();
130
+ }
131
+ }
132
+ transformQuery({ node, queryId, }) {
133
+ if (this.skipNodeSet?.has(node.kind)) {
134
+ this.ctx?.add(queryId);
135
+ return node;
136
+ }
137
+ return this.transformer.transformNode(node);
138
+ }
139
+ async transformResult({ result, queryId, }) {
140
+ return this.ctx?.has(queryId)
141
+ ? result
142
+ : { ...result, rows: this.parseRows(result.rows) };
143
+ }
144
+ parseRows(rows) {
145
+ const result = [];
146
+ for (const row of rows) {
147
+ if (!row) {
148
+ continue;
149
+ }
150
+ const parsedRow = {};
151
+ for (const [key, value] of Object.entries(row)) {
152
+ parsedRow[key] = this.deserializer(value);
153
+ }
154
+ result.push(parsedRow);
155
+ }
156
+ return result;
157
+ }
158
+ }
159
+ export class SerializePlugin extends BaseSerializePlugin {
160
+ /**
161
+ * _**THIS PLUGIN SHOULD BE PLACED AT THE END OF PLUGINS ARRAY !!!**_
162
+ *
163
+ * reference from https://github.com/koskimas/kysely/pull/138
164
+ *
165
+ * Serializes object/array/Date/boolean column values to strings for
166
+ * SQLite-style dialects and deserializes them on the way back out. Pass a
167
+ * custom `serializer`/`deserializer` to override the default behaviour.
168
+ */
169
+ constructor(options = {}) {
170
+ const { deserializer = defaultDeserializer, serializer = defaultSerializer, skipNodeKind = [], } = options;
171
+ super(serializer, deserializer, skipNodeKind);
172
+ }
173
+ }
174
+ /**
175
+ * @deprecated use {@link SerializePlugin} instead
176
+ */
177
+ export const SqliteSerializePlugin = SerializePlugin;
@@ -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","../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-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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.12.15",
3
+ "version": "0.12.17",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/src/index.js",
@@ -20,16 +20,15 @@
20
20
  "prepublishOnly": "yarn build"
21
21
  },
22
22
  "peerDependencies": {
23
- "@pikku/core": "^0.12.28"
23
+ "@pikku/core": "^0.12.38"
24
24
  },
25
25
  "dependencies": {
26
- "kysely": "^0.28.12"
26
+ "kysely": "^0.29.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/better-sqlite3": "^7.6.13",
30
30
  "better-sqlite3": "^12.10.0",
31
31
  "kysely-codegen": "^0.20.0",
32
- "kysely-plugin-serialize": "^0.8.2",
33
32
  "kysely-postgres-js": "^3.0.0",
34
33
  "postgres": "^3.4.8",
35
34
  "typescript": "^5.9"
package/src/index.ts CHANGED
@@ -17,6 +17,20 @@ export {
17
17
  type CreateAuditedKyselyOptions,
18
18
  } from './create-audited-kysely.js'
19
19
 
20
+ export {
21
+ SerializePlugin,
22
+ SqliteSerializePlugin,
23
+ BaseSerializePlugin,
24
+ defaultSerializer,
25
+ defaultDeserializer,
26
+ maybeJson,
27
+ skipTransform,
28
+ dateRegex,
29
+ type Serializer,
30
+ type Deserializer,
31
+ type SerializePluginOptions,
32
+ } from './serialize-plugin.js'
33
+
20
34
  export type { KyselyPikkuDB } from './kysely-tables.js'
21
35
  export type { WorkflowRunService } from '@pikku/core/workflow'
22
36
  export type { AgentRunService, AgentRunRow } from '@pikku/core/ai-agent'
@@ -149,7 +149,9 @@ export class KyselySecretService implements SecretService {
149
149
  await this.logAudit(key, 'delete')
150
150
  }
151
151
 
152
- async getSecrets(keys: string[]): Promise<Record<string, unknown>> {
152
+ async getSecrets<
153
+ T extends Record<string, unknown> = Record<string, unknown>,
154
+ >(keys: (keyof T & string)[]): Promise<T> {
153
155
  const rows = await this.db
154
156
  .selectFrom('secrets')
155
157
  .select(['key', 'ciphertext', 'wrappedDek', 'keyVersion'])
@@ -165,11 +167,21 @@ export class KyselySecretService implements SecretService {
165
167
  row.ciphertext,
166
168
  row.wrappedDek
167
169
  )
168
- } catch {
169
- // skip secrets that fail to decrypt
170
+ } catch (cause) {
171
+ // A stored secret that fails to decrypt is never expected — it means the
172
+ // KEK does not match what the value was wrapped under (mismatched
173
+ // PIKKU_SECRET_KEK, or a missing previousKey for an older key_version).
174
+ // Swallowing it produces an undefined secret and an opaque downstream
175
+ // failure (e.g. an auth "server configuration" 500). Fail loud, naming
176
+ // the key and key_version so the misconfiguration is diagnosable.
177
+ throw new Error(
178
+ `Failed to decrypt secret "${row.key}" (key_version ${row.keyVersion}): ` +
179
+ `the configured KEK does not match the key it was wrapped under`,
180
+ { cause }
181
+ )
170
182
  }
171
183
  }
172
- return out
184
+ return out as T
173
185
  }
174
186
 
175
187
  async rotateKEK(): Promise<number> {
@@ -1,7 +1,7 @@
1
1
  import { describe, test, before, after } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
4
- import { SerializePlugin } from 'kysely-plugin-serialize'
4
+ import { SerializePlugin } from './serialize-plugin.js'
5
5
  import Database from 'better-sqlite3'
6
6
  import { PostgresJSDialect } from 'kysely-postgres-js'
7
7
  import postgres from 'postgres'
@@ -51,6 +51,7 @@ export interface WorkflowStepTable {
51
51
  branchTaken: string | null
52
52
  retries: number | null
53
53
  retryDelay: string | null
54
+ fromStepName: string | null
54
55
  createdAt: Generated<Date>
55
56
  updatedAt: Generated<Date>
56
57
  }
@@ -1,7 +1,7 @@
1
1
  import { describe, test, beforeEach, afterEach } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
4
- import { SerializePlugin } from 'kysely-plugin-serialize'
4
+ import { SerializePlugin } from './serialize-plugin.js'
5
5
  import Database from 'better-sqlite3'
6
6
 
7
7
  import type { KyselyPikkuDB } from './kysely-tables.js'
@@ -5,7 +5,6 @@ import type {
5
5
  WorkflowRunService,
6
6
  } from '@pikku/core/workflow'
7
7
  import type { Kysely } from 'kysely'
8
- import { sql } from 'kysely'
9
8
  import type { KyselyPikkuDB } from './kysely-tables.js'
10
9
  import { parseJson } from './kysely-json.js'
11
10
 
@@ -112,14 +111,43 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
112
111
  )
113
112
  .as('attemptCount')
114
113
  )
115
- .select(
116
- sql<string | null>`(SELECT MAX(h.running_at) FROM workflow_step_history h WHERE h.workflow_step_id = s.workflow_step_id)`.as('runningAt')
114
+ // Build these correlated subqueries through the query builder rather
115
+ // than a raw `sql` fragment so the active schema (e.g.
116
+ // `withSchema('app')`) is applied to `workflow_step_history`. A raw
117
+ // fragment hardcodes an unqualified table name and dies against a
118
+ // connection whose search_path does not include the schema.
119
+ .select((eb) =>
120
+ eb
121
+ .selectFrom('workflowStepHistory')
122
+ .select((eb2) => eb2.fn.max('workflowStepHistory.runningAt').as('m'))
123
+ .whereRef(
124
+ 'workflowStepHistory.workflowStepId',
125
+ '=',
126
+ 's.workflowStepId'
127
+ )
128
+ .as('runningAt')
117
129
  )
118
- .select(
119
- sql<string | null>`(SELECT MAX(h.succeeded_at) FROM workflow_step_history h WHERE h.workflow_step_id = s.workflow_step_id)`.as('succeededAt')
130
+ .select((eb) =>
131
+ eb
132
+ .selectFrom('workflowStepHistory')
133
+ .select((eb2) => eb2.fn.max('workflowStepHistory.succeededAt').as('m'))
134
+ .whereRef(
135
+ 'workflowStepHistory.workflowStepId',
136
+ '=',
137
+ 's.workflowStepId'
138
+ )
139
+ .as('succeededAt')
120
140
  )
121
- .select(
122
- sql<string | null>`(SELECT MAX(h.failed_at) FROM workflow_step_history h WHERE h.workflow_step_id = s.workflow_step_id)`.as('failedAt')
141
+ .select((eb) =>
142
+ eb
143
+ .selectFrom('workflowStepHistory')
144
+ .select((eb2) => eb2.fn.max('workflowStepHistory.failedAt').as('m'))
145
+ .whereRef(
146
+ 'workflowStepHistory.workflowStepId',
147
+ '=',
148
+ 's.workflowStepId'
149
+ )
150
+ .as('failedAt')
123
151
  )
124
152
  .where('s.workflowRunId', '=', runId)
125
153
  .orderBy('s.createdAt', 'asc')
@@ -5,6 +5,7 @@ import {
5
5
  type WorkflowRun,
6
6
  type WorkflowRunWire,
7
7
  type StepState,
8
+ type StepStatus,
8
9
  type WorkflowStatus,
9
10
  type WorkflowVersionStatus,
10
11
  } from '@pikku/core/workflow'
@@ -79,6 +80,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
79
80
  .addColumn('branch_taken', 'text')
80
81
  .addColumn('retries', 'integer')
81
82
  .addColumn('retry_delay', 'text')
83
+ .addColumn('from_step_name', 'text')
82
84
  .addColumn('created_at', 'timestamp', (col) =>
83
85
  col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
84
86
  )
@@ -91,6 +93,15 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
91
93
  ])
92
94
  .execute()
93
95
 
96
+ // Additive: backfill the column on tables created before from_step_name.
97
+ await this.db.schema
98
+ .alterTable('workflow_step')
99
+ .addColumn('from_step_name', 'text')
100
+ .execute()
101
+ .catch(() => {
102
+ // Column already exists (fresh tables include it above) — nothing to do.
103
+ })
104
+
94
105
  await this.db.schema
95
106
  .createTable('workflow_step_history')
96
107
  .ifNotExists()
@@ -196,7 +207,8 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
196
207
  stepName: string,
197
208
  rpcName: string | null,
198
209
  data: any,
199
- stepOptions?: { retries?: number; retryDelay?: string | number }
210
+ stepOptions?: { retries?: number; retryDelay?: string | number },
211
+ fromStepName?: string
200
212
  ): Promise<StepState> {
201
213
  const stepId = crypto.randomUUID()
202
214
  const now = new Date()
@@ -212,6 +224,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
212
224
  status: 'pending',
213
225
  retries: stepOptions?.retries ?? null,
214
226
  retryDelay: stepOptions?.retryDelay?.toString() ?? null,
227
+ fromStepName: fromStepName ?? null,
215
228
  createdAt: now,
216
229
  updatedAt: now,
217
230
  })
@@ -227,6 +240,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
227
240
  attemptCount: 1,
228
241
  retries: stepOptions?.retries,
229
242
  retryDelay: stepOptions?.retryDelay?.toString(),
243
+ fromStepName,
230
244
  createdAt: now,
231
245
  updatedAt: now,
232
246
  }
@@ -242,6 +256,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
242
256
  's.error',
243
257
  's.retries',
244
258
  's.retryDelay',
259
+ 's.fromStepName',
245
260
  's.createdAt',
246
261
  's.updatedAt',
247
262
  ])
@@ -274,6 +289,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
274
289
  attemptCount: Number(row.attemptCount),
275
290
  retries: row.retries != null ? Number(row.retries) : undefined,
276
291
  retryDelay: row.retryDelay ?? undefined,
292
+ fromStepName: row.fromStepName ?? undefined,
277
293
  createdAt: new Date(row.createdAt),
278
294
  updatedAt: new Date(row.updatedAt),
279
295
  }
@@ -467,6 +483,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
467
483
  's.error',
468
484
  's.retries',
469
485
  's.retryDelay',
486
+ 's.fromStepName',
470
487
  's.createdAt',
471
488
  's.updatedAt',
472
489
  ])
@@ -492,6 +509,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
492
509
  attemptCount: Number(row.attemptCount),
493
510
  retries: row.retries != null ? Number(row.retries) : undefined,
494
511
  retryDelay: row.retryDelay ?? undefined,
512
+ fromStepName: row.fromStepName ?? undefined,
495
513
  createdAt: new Date(row.createdAt),
496
514
  updatedAt: new Date(row.updatedAt),
497
515
  }
@@ -568,6 +586,21 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
568
586
  return nodeIds.filter((id) => !existingStepNames.has(id))
569
587
  }
570
588
 
589
+ async getStepInstances(runId: string): Promise<
590
+ Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
591
+ > {
592
+ const rows = await this.db
593
+ .selectFrom('workflowStep')
594
+ .select(['stepName', 'status', 'fromStepName'])
595
+ .where('workflowRunId', '=', runId)
596
+ .execute()
597
+ return rows.map((r) => ({
598
+ stepName: r.stepName,
599
+ status: r.status as StepStatus,
600
+ fromStepName: r.fromStepName ?? undefined,
601
+ }))
602
+ }
603
+
571
604
  async getNodeResults(
572
605
  runId: string,
573
606
  nodeIds: string[]
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Serialize / deserialize plugin for Kysely.
3
+ *
4
+ * Adapted from `kysely-plugin-serialize` (MIT, by subframe7536 —
5
+ * https://github.com/subframe7536/kysely-plugin-serialize), which is
6
+ * unmaintained and ships no `exports` map, so modern Node16/ESM resolution
7
+ * falls back to its CommonJS build and cannot import ESM-only kysely (>= 0.29).
8
+ * Maintained here directly as part of @pikku/kysely so we publish it ourselves.
9
+ *
10
+ * Serializes object/array/Date/boolean parameters to strings on the way into
11
+ * SQLite-style dialects, and deserializes them back on the way out.
12
+ */
13
+ import {
14
+ OperationNodeTransformer,
15
+ type ColumnUpdateNode,
16
+ type KyselyPlugin,
17
+ type PluginTransformQueryArgs,
18
+ type PluginTransformResultArgs,
19
+ type PrimitiveValueListNode,
20
+ type QueryId,
21
+ type QueryResult,
22
+ type RootOperationNode,
23
+ type UnknownRow,
24
+ type ValueNode,
25
+ } from 'kysely'
26
+
27
+ export type Serializer = (parameter: unknown) => unknown
28
+ export type Deserializer = (parameter: unknown) => unknown
29
+
30
+ class SerializeParametersTransformer extends OperationNodeTransformer {
31
+ private readonly serializer: Serializer
32
+
33
+ constructor(serializer: Serializer) {
34
+ super()
35
+ this.serializer = serializer
36
+ }
37
+
38
+ protected override transformPrimitiveValueList(
39
+ node: PrimitiveValueListNode
40
+ ): PrimitiveValueListNode {
41
+ return {
42
+ ...node,
43
+ values: node.values.map(this.serializer),
44
+ }
45
+ }
46
+
47
+ // https://www.npmjs.com/package/zodsql
48
+ protected override transformColumnUpdate(
49
+ node: ColumnUpdateNode
50
+ ): ColumnUpdateNode {
51
+ const { value: valueNode } = node
52
+ if (valueNode.kind !== 'ValueNode') {
53
+ return super.transformColumnUpdate(node)
54
+ }
55
+ const { value, ...item } = valueNode as ValueNode
56
+ const serializedValue = this.serializer(value)
57
+ if (value === serializedValue) {
58
+ return super.transformColumnUpdate(node)
59
+ }
60
+ const serializedNode: ValueNode = { ...item, value: serializedValue }
61
+ return super.transformColumnUpdate({ ...node, value: serializedNode })
62
+ }
63
+
64
+ protected override transformValue(node: ValueNode): ValueNode {
65
+ return {
66
+ ...node,
67
+ value: this.serializer(node.value),
68
+ }
69
+ }
70
+ }
71
+
72
+ export const defaultSerializer: Serializer = (parameter) => {
73
+ if (skipTransform(parameter) || typeof parameter === 'string') {
74
+ return parameter
75
+ } else if (typeof parameter === 'boolean') {
76
+ return '' + parameter
77
+ } else if (parameter instanceof Date) {
78
+ return parameter.toISOString()
79
+ } else {
80
+ try {
81
+ return JSON.stringify(parameter)
82
+ } catch {
83
+ return parameter
84
+ }
85
+ }
86
+ }
87
+
88
+ export const dateRegex = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?$/
89
+
90
+ export const defaultDeserializer: Deserializer = (parameter) => {
91
+ if (skipTransform(parameter)) {
92
+ return parameter
93
+ }
94
+ if (typeof parameter === 'string') {
95
+ if (parameter === 'true') {
96
+ return true
97
+ } else if (parameter === 'false') {
98
+ return false
99
+ } else if (dateRegex.test(parameter)) {
100
+ return new Date(parameter)
101
+ } else if (maybeJson(parameter)) {
102
+ try {
103
+ return JSON.parse(parameter)
104
+ } catch {
105
+ // not valid JSON — fall through and return the raw string
106
+ }
107
+ }
108
+ return parameter
109
+ }
110
+ return parameter
111
+ }
112
+
113
+ /**
114
+ * Checks if a given string parameter is a JSON-like string.
115
+ *
116
+ * Determines whether the input starts and ends with curly braces `{}` or
117
+ * square brackets `[]`, the typical indicators of JSON objects and arrays.
118
+ */
119
+ export function maybeJson(parameter: string): boolean {
120
+ return (
121
+ (parameter.startsWith('{') && parameter.endsWith('}')) ||
122
+ (parameter.startsWith('[') && parameter.endsWith(']'))
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Determines whether a given parameter should be skipped during transformation.
128
+ *
129
+ * Skipped when it is `undefined`, `null`, a `bigint`, a `number`, or an object
130
+ * that exposes a `buffer` property (e.g. a typed array / Buffer).
131
+ */
132
+ export function skipTransform(parameter: unknown): boolean {
133
+ return (
134
+ parameter === undefined ||
135
+ parameter === null ||
136
+ typeof parameter === 'bigint' ||
137
+ typeof parameter === 'number' ||
138
+ (typeof parameter === 'object' && 'buffer' in parameter)
139
+ )
140
+ }
141
+
142
+ export class BaseSerializePlugin implements KyselyPlugin {
143
+ private readonly transformer: SerializeParametersTransformer
144
+ private readonly deserializer: Deserializer
145
+ private readonly skipNodeSet?: Set<RootOperationNode['kind']>
146
+ private readonly ctx?: WeakSet<QueryId>
147
+
148
+ /**
149
+ * Base class for {@link SerializePlugin}, without default options.
150
+ */
151
+ constructor(
152
+ serializer: Serializer,
153
+ deserializer: Deserializer,
154
+ skipNodeKind: Array<RootOperationNode['kind']>
155
+ ) {
156
+ this.transformer = new SerializeParametersTransformer(serializer)
157
+ this.deserializer = deserializer
158
+ if (skipNodeKind.length) {
159
+ this.skipNodeSet = new Set(skipNodeKind)
160
+ this.ctx = new WeakSet()
161
+ }
162
+ }
163
+
164
+ transformQuery({
165
+ node,
166
+ queryId,
167
+ }: PluginTransformQueryArgs): RootOperationNode {
168
+ if (this.skipNodeSet?.has(node.kind)) {
169
+ this.ctx?.add(queryId)
170
+ return node
171
+ }
172
+ return this.transformer.transformNode(node)
173
+ }
174
+
175
+ async transformResult({
176
+ result,
177
+ queryId,
178
+ }: PluginTransformResultArgs): Promise<QueryResult<UnknownRow>> {
179
+ return this.ctx?.has(queryId)
180
+ ? result
181
+ : { ...result, rows: this.parseRows(result.rows) }
182
+ }
183
+
184
+ private parseRows(rows: UnknownRow[]): UnknownRow[] {
185
+ const result: UnknownRow[] = []
186
+ for (const row of rows) {
187
+ if (!row) {
188
+ continue
189
+ }
190
+ const parsedRow: UnknownRow = {}
191
+ for (const [key, value] of Object.entries(row)) {
192
+ parsedRow[key] = this.deserializer(value)
193
+ }
194
+ result.push(parsedRow)
195
+ }
196
+ return result
197
+ }
198
+ }
199
+
200
+ export interface SerializePluginOptions {
201
+ /**
202
+ * serialize params
203
+ */
204
+ serializer?: Serializer
205
+ /**
206
+ * deserialize params
207
+ */
208
+ deserializer?: Deserializer
209
+ /**
210
+ * node kind to skip transform
211
+ */
212
+ skipNodeKind?: Array<RootOperationNode['kind']>
213
+ }
214
+
215
+ export class SerializePlugin extends BaseSerializePlugin {
216
+ /**
217
+ * _**THIS PLUGIN SHOULD BE PLACED AT THE END OF PLUGINS ARRAY !!!**_
218
+ *
219
+ * reference from https://github.com/koskimas/kysely/pull/138
220
+ *
221
+ * Serializes object/array/Date/boolean column values to strings for
222
+ * SQLite-style dialects and deserializes them on the way back out. Pass a
223
+ * custom `serializer`/`deserializer` to override the default behaviour.
224
+ */
225
+ constructor(options: SerializePluginOptions = {}) {
226
+ const {
227
+ deserializer = defaultDeserializer,
228
+ serializer = defaultSerializer,
229
+ skipNodeKind = [],
230
+ } = options
231
+ super(serializer, deserializer, skipNodeKind)
232
+ }
233
+ }
234
+
235
+ /**
236
+ * @deprecated use {@link SerializePlugin} instead
237
+ */
238
+ export const SqliteSerializePlugin = SerializePlugin