@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 +84 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/kysely-secret-service.d.ts +1 -1
- package/dist/src/kysely-secret-service.js +9 -2
- package/dist/src/kysely-tables.d.ts +1 -0
- package/dist/src/kysely-workflow-run-service.js +20 -4
- package/dist/src/kysely-workflow-service.d.ts +7 -2
- package/dist/src/kysely-workflow-service.js +28 -1
- package/dist/src/serialize-plugin.d.ts +75 -0
- package/dist/src/serialize-plugin.js +177 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -4
- package/src/index.ts +14 -0
- package/src/kysely-secret-service.ts +16 -4
- package/src/kysely-services.test.ts +1 -1
- package/src/kysely-tables.ts +1 -0
- package/src/kysely-workflow-mirror.test.ts +1 -1
- package/src/kysely-workflow-run-service.ts +35 -7
- package/src/kysely-workflow-service.ts +34 -1
- package/src/serialize-plugin.ts +238 -0
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
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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<
|
|
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
|
-
//
|
|
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;
|
|
@@ -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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
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.
|
|
23
|
+
"@pikku/core": "^0.12.38"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"kysely": "^0.
|
|
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
|
|
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
|
-
//
|
|
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 '
|
|
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'
|
package/src/kysely-tables.ts
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|