@pikku/kysely 0.13.23 → 0.13.27
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 +248 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-analytics-service.d.ts +30 -0
- package/dist/src/kysely-analytics-service.js +80 -0
- package/dist/src/kysely-feature-flag-store.d.ts +37 -0
- package/dist/src/kysely-feature-flag-store.js +239 -0
- package/dist/src/kysely-tables.d.ts +45 -0
- package/dist/src/kysely-webhook-service.d.ts +1 -1
- package/dist/src/kysely-webhook-service.js +1 -1
- package/dist/src/schema/analytics.schema.d.ts +17 -0
- package/dist/src/schema/analytics.schema.js +48 -0
- package/dist/src/schema/flag.schema.d.ts +16 -0
- package/dist/src/schema/flag.schema.js +62 -0
- package/dist/src/schema/index.d.ts +2 -0
- package/dist/src/schema/index.js +6 -0
- package/package.json +11 -6
- package/run-tests.sh +9 -1
- package/bin/pikku-kysely-pure.ts +0 -59
- package/dist/src/kysely-lock-vault.d.ts +0 -34
- package/dist/src/kysely-lock-vault.js +0 -77
- package/dist/src/schema/data-lock.schema.d.ts +0 -15
- package/dist/src/schema/data-lock.schema.js +0 -27
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/classification-crypto.test.ts +0 -143
- package/src/classification-crypto.ts +0 -163
- package/src/classification-plugin.pglite.test.ts +0 -226
- package/src/classification-plugin.test.ts +0 -265
- package/src/classification-plugin.ts +0 -249
- package/src/coercion-plugin.test.ts +0 -160
- package/src/coercion-plugin.ts +0 -187
- package/src/create-audited-kysely.test.ts +0 -560
- package/src/create-audited-kysely.ts +0 -254
- package/src/index.ts +0 -81
- package/src/kysely-agent-run-scores.ts +0 -47
- package/src/kysely-agent-run-service.ts +0 -244
- package/src/kysely-agent-run-state-service.ts +0 -200
- package/src/kysely-agent-storage-service.ts +0 -632
- package/src/kysely-audit-service.test.ts +0 -352
- package/src/kysely-audit-service.ts +0 -237
- package/src/kysely-channel-store.ts +0 -107
- package/src/kysely-credential-service.ts +0 -286
- package/src/kysely-deployment-service.ts +0 -163
- package/src/kysely-eventhub-store.ts +0 -53
- package/src/kysely-json.ts +0 -5
- package/src/kysely-scope-service.test.ts +0 -418
- package/src/kysely-scope-service.ts +0 -425
- package/src/kysely-secret-service.ts +0 -242
- package/src/kysely-services.test.ts +0 -405
- package/src/kysely-session-store.ts +0 -57
- package/src/kysely-tables.test.ts +0 -110
- package/src/kysely-tables.ts +0 -434
- package/src/kysely-virtual-user-run-store.test.ts +0 -369
- package/src/kysely-virtual-user-run-store.ts +0 -235
- package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
- package/src/kysely-virtual-user-schedule-store.ts +0 -187
- package/src/kysely-webhook-service.test.ts +0 -124
- package/src/kysely-webhook-service.ts +0 -137
- package/src/kysely-workflow-mirror.test.ts +0 -259
- package/src/kysely-workflow-mirror.ts +0 -374
- package/src/kysely-workflow-run-service.ts +0 -305
- package/src/kysely-workflow-service.test.ts +0 -544
- package/src/kysely-workflow-service.ts +0 -820
- package/src/schema/agent.schema.ts +0 -162
- package/src/schema/audit.schema.ts +0 -57
- package/src/schema/channel.schema.ts +0 -38
- package/src/schema/credential.schema.ts +0 -58
- package/src/schema/deployment.schema.ts +0 -47
- package/src/schema/index.ts +0 -441
- package/src/schema/pikku-schema.types.ts +0 -169
- package/src/schema/schema.test.ts +0 -591
- package/src/schema/scope.schema.ts +0 -110
- package/src/schema/secret.schema.ts +0 -49
- package/src/schema/session.schema.ts +0 -20
- package/src/schema/virtual-user-schedule.schema.ts +0 -48
- package/src/schema/virtual-user.schema.ts +0 -106
- package/src/schema/webhook.schema.ts +0 -58
- package/src/schema/workflow.schema.ts +0 -150
- package/src/serialize-plugin.ts +0 -233
- package/tsconfig.json +0 -17
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { CachedFlagSource, subjectIdOf } from '@pikku/core/flag';
|
|
2
|
+
import { requirePikkuSchema } from './schema/index.js';
|
|
3
|
+
import { flagSchema } from './schema/flag.schema.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reads the stored `anyOf`, which arrives as either shape.
|
|
6
|
+
*
|
|
7
|
+
* The column holds JSON text, but `SerializePlugin` parses it back on the way
|
|
8
|
+
* out of a sqlite-style dialect while postgres hands over the string as
|
|
9
|
+
* written. One store serving both engines has to accept both, and the column
|
|
10
|
+
* type can only describe one of them.
|
|
11
|
+
*/
|
|
12
|
+
const parseAnyOf = (value) => {
|
|
13
|
+
if (!value)
|
|
14
|
+
return undefined;
|
|
15
|
+
const parsed = typeof value === 'string'
|
|
16
|
+
? (() => {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(value);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
})()
|
|
24
|
+
: value;
|
|
25
|
+
return Array.isArray(parsed)
|
|
26
|
+
? parsed.filter((v) => typeof v === 'string')
|
|
27
|
+
: undefined;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Feature flags backed by two tables pikku owns.
|
|
31
|
+
*
|
|
32
|
+
* The full store, not just a source: the declaration lifecycle and the operator
|
|
33
|
+
* controls both live here, which is what makes the console's Flags tab
|
|
34
|
+
* writable. A third-party provider implements `FeatureFlagSource` alone.
|
|
35
|
+
*/
|
|
36
|
+
export class KyselyFeatureFlagStore extends CachedFlagSource {
|
|
37
|
+
db;
|
|
38
|
+
initialized = false;
|
|
39
|
+
constructor(db, options = {}) {
|
|
40
|
+
super({ ttlMs: 10_000, ...options });
|
|
41
|
+
this.db = db;
|
|
42
|
+
}
|
|
43
|
+
async init() {
|
|
44
|
+
if (this.initialized)
|
|
45
|
+
return;
|
|
46
|
+
await requirePikkuSchema(this.db, flagSchema);
|
|
47
|
+
this.initialized = true;
|
|
48
|
+
}
|
|
49
|
+
async fetchSnapshot() {
|
|
50
|
+
const [flags, overrides] = await Promise.all([
|
|
51
|
+
this.db
|
|
52
|
+
.selectFrom('pikkuFeatureFlags')
|
|
53
|
+
.select(['name', 'enabled', 'rolloutPercent'])
|
|
54
|
+
.execute(),
|
|
55
|
+
this.db
|
|
56
|
+
.selectFrom('pikkuFeatureFlagOverrides')
|
|
57
|
+
.select(['flag', 'subjectId', 'enabled'])
|
|
58
|
+
.execute(),
|
|
59
|
+
]);
|
|
60
|
+
const snapshot = {};
|
|
61
|
+
for (const flag of flags) {
|
|
62
|
+
snapshot[flag.name] = {
|
|
63
|
+
enabled: !!flag.enabled,
|
|
64
|
+
rolloutPercent: flag.rolloutPercent ?? null,
|
|
65
|
+
overrides: {},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
for (const override of overrides) {
|
|
69
|
+
const row = snapshot[override.flag];
|
|
70
|
+
if (row) {
|
|
71
|
+
row.overrides[override.subjectId] = !!override.enabled;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return snapshot;
|
|
75
|
+
}
|
|
76
|
+
async syncFlags(flags) {
|
|
77
|
+
this.setDeclared(flags);
|
|
78
|
+
await this.db.transaction().execute(async (trx) => {
|
|
79
|
+
for (const flag of flags) {
|
|
80
|
+
await trx
|
|
81
|
+
.insertInto('pikkuFeatureFlags')
|
|
82
|
+
.values({
|
|
83
|
+
name: flag.name,
|
|
84
|
+
description: flag.description ?? null,
|
|
85
|
+
anyOf: flag.anyOf ? JSON.stringify(flag.anyOf) : null,
|
|
86
|
+
declared: true,
|
|
87
|
+
})
|
|
88
|
+
.onConflict((oc) =>
|
|
89
|
+
// `enabled`, `rolloutPercent` and every override are the operator's
|
|
90
|
+
// and are never touched here. A deploy that re-enabled a flag
|
|
91
|
+
// somebody killed an hour earlier would make the kill switch
|
|
92
|
+
// useless exactly when it is being relied on.
|
|
93
|
+
oc.column('name').doUpdateSet((eb) => ({
|
|
94
|
+
description: eb.ref('excluded.description'),
|
|
95
|
+
anyOf: eb.ref('excluded.anyOf'),
|
|
96
|
+
declared: true,
|
|
97
|
+
})))
|
|
98
|
+
.execute();
|
|
99
|
+
}
|
|
100
|
+
const markStale = trx
|
|
101
|
+
.updateTable('pikkuFeatureFlags')
|
|
102
|
+
.set({ declared: false });
|
|
103
|
+
await (flags.length > 0
|
|
104
|
+
? markStale.where('name', 'not in', flags.map((f) => f.name))
|
|
105
|
+
: markStale).execute();
|
|
106
|
+
});
|
|
107
|
+
this.invalidate();
|
|
108
|
+
}
|
|
109
|
+
async listFlags() {
|
|
110
|
+
const rows = await this.db
|
|
111
|
+
.selectFrom('pikkuFeatureFlags')
|
|
112
|
+
.selectAll()
|
|
113
|
+
.orderBy('name')
|
|
114
|
+
.execute();
|
|
115
|
+
return rows.map((row) => ({
|
|
116
|
+
name: row.name,
|
|
117
|
+
description: row.description ?? undefined,
|
|
118
|
+
anyOf: parseAnyOf(row.anyOf),
|
|
119
|
+
enabled: !!row.enabled,
|
|
120
|
+
rolloutPercent: row.rolloutPercent ?? null,
|
|
121
|
+
declared: !!row.declared,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
async listOverrides(key) {
|
|
125
|
+
const rows = await this.db
|
|
126
|
+
.selectFrom('pikkuFeatureFlagOverrides')
|
|
127
|
+
.selectAll()
|
|
128
|
+
.where('flag', '=', key)
|
|
129
|
+
.orderBy('subjectId')
|
|
130
|
+
.execute();
|
|
131
|
+
return rows.map((row) => ({
|
|
132
|
+
subjectId: row.subjectId,
|
|
133
|
+
subjectKind: row.subjectKind,
|
|
134
|
+
enabled: !!row.enabled,
|
|
135
|
+
grantedBy: row.grantedBy ?? undefined,
|
|
136
|
+
// ISO 8601 rather than the driver's date: the row crosses an RPC boundary
|
|
137
|
+
// to reach the console, and every driver spells a timestamp differently.
|
|
138
|
+
grantedAt: row.grantedAt
|
|
139
|
+
? new Date(row.grantedAt).toISOString()
|
|
140
|
+
: undefined,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
async setEnabled(key, enabled, actor, note) {
|
|
144
|
+
await this.db
|
|
145
|
+
.updateTable('pikkuFeatureFlags')
|
|
146
|
+
.set({
|
|
147
|
+
enabled,
|
|
148
|
+
updatedBy: actor ?? null,
|
|
149
|
+
note: note ?? null,
|
|
150
|
+
updatedAt: new Date(),
|
|
151
|
+
})
|
|
152
|
+
.where('name', '=', key)
|
|
153
|
+
.execute();
|
|
154
|
+
this.invalidate();
|
|
155
|
+
}
|
|
156
|
+
async setRollout(key, percent, actor) {
|
|
157
|
+
if (percent !== null && (percent < 0 || percent > 100)) {
|
|
158
|
+
throw new Error(`Feature flag '${key}': rollout must be between 0 and 100, got ${percent}.`);
|
|
159
|
+
}
|
|
160
|
+
await this.db
|
|
161
|
+
.updateTable('pikkuFeatureFlags')
|
|
162
|
+
.set({
|
|
163
|
+
rolloutPercent: percent === null ? null : Math.round(percent),
|
|
164
|
+
updatedBy: actor ?? null,
|
|
165
|
+
updatedAt: new Date(),
|
|
166
|
+
})
|
|
167
|
+
.where('name', '=', key)
|
|
168
|
+
.execute();
|
|
169
|
+
this.invalidate();
|
|
170
|
+
}
|
|
171
|
+
async setOverride(key, subject, enabled, actor) {
|
|
172
|
+
const subjectId = subjectIdOf(subject);
|
|
173
|
+
if (!subjectId) {
|
|
174
|
+
throw new Error(`Feature flag '${key}': an override needs an organization or a user to be about.`);
|
|
175
|
+
}
|
|
176
|
+
await this.db
|
|
177
|
+
.insertInto('pikkuFeatureFlagOverrides')
|
|
178
|
+
.values({
|
|
179
|
+
flag: key,
|
|
180
|
+
subjectId,
|
|
181
|
+
subjectKind: subject.organizationId ? 'organization' : 'user',
|
|
182
|
+
enabled,
|
|
183
|
+
grantedBy: actor ?? null,
|
|
184
|
+
grantedAt: new Date(),
|
|
185
|
+
})
|
|
186
|
+
.onConflict((oc) => oc.columns(['flag', 'subjectId']).doUpdateSet((eb) => ({
|
|
187
|
+
enabled: eb.ref('excluded.enabled'),
|
|
188
|
+
subjectKind: eb.ref('excluded.subjectKind'),
|
|
189
|
+
grantedBy: eb.ref('excluded.grantedBy'),
|
|
190
|
+
// The column defaults on insert only, and the panel reads this as
|
|
191
|
+
// when the pin was last granted — not when the subject was first
|
|
192
|
+
// pinned to something else.
|
|
193
|
+
grantedAt: eb.ref('excluded.grantedAt'),
|
|
194
|
+
})))
|
|
195
|
+
.execute();
|
|
196
|
+
this.invalidate();
|
|
197
|
+
}
|
|
198
|
+
async clearOverride(key, subject) {
|
|
199
|
+
const subjectId = subjectIdOf(subject);
|
|
200
|
+
if (!subjectId) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await this.db
|
|
204
|
+
.deleteFrom('pikkuFeatureFlagOverrides')
|
|
205
|
+
.where('flag', '=', key)
|
|
206
|
+
.where('subjectId', '=', subjectId)
|
|
207
|
+
.execute();
|
|
208
|
+
this.invalidate();
|
|
209
|
+
}
|
|
210
|
+
async findStaleFlags() {
|
|
211
|
+
const rows = await this.db
|
|
212
|
+
.selectFrom('pikkuFeatureFlags')
|
|
213
|
+
.select('name')
|
|
214
|
+
.where('declared', '=', false)
|
|
215
|
+
.orderBy('name')
|
|
216
|
+
.execute();
|
|
217
|
+
return rows.map((row) => row.name);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Deletes what is still undeclared, and reports only what it deleted.
|
|
221
|
+
*
|
|
222
|
+
* The `declared = false` predicate is repeated on the delete rather than
|
|
223
|
+
* carried over from the read: a deploy running `syncFlags` between the two
|
|
224
|
+
* would redeclare a flag, and deleting on the earlier answer would take a
|
|
225
|
+
* live flag and cascade away every override an operator had set on it.
|
|
226
|
+
*/
|
|
227
|
+
async pruneFlags() {
|
|
228
|
+
const deleted = await this.db
|
|
229
|
+
.deleteFrom('pikkuFeatureFlags')
|
|
230
|
+
.where('declared', '=', false)
|
|
231
|
+
.returning('name')
|
|
232
|
+
.execute();
|
|
233
|
+
if (deleted.length === 0) {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
this.invalidate();
|
|
237
|
+
return deleted.map((row) => row.name).sort();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -222,6 +222,29 @@ export interface PikkuRolesTable {
|
|
|
222
222
|
declared: Generated<boolean>;
|
|
223
223
|
createdAt: Generated<Date>;
|
|
224
224
|
}
|
|
225
|
+
/** A feature flag's operator state. What code declared lives beside it. */
|
|
226
|
+
export interface PikkuFeatureFlagsTable {
|
|
227
|
+
name: string;
|
|
228
|
+
description: string | null;
|
|
229
|
+
/** The declared `anyOf` scope list as JSON, or null for a pure switch. */
|
|
230
|
+
anyOf: string | null;
|
|
231
|
+
enabled: Generated<boolean>;
|
|
232
|
+
rolloutPercent: number | null;
|
|
233
|
+
/** False for a flag whose declaration has gone: switchable, but not offered. */
|
|
234
|
+
declared: Generated<boolean>;
|
|
235
|
+
updatedBy: string | null;
|
|
236
|
+
note: string | null;
|
|
237
|
+
updatedAt: Generated<Date>;
|
|
238
|
+
}
|
|
239
|
+
/** One subject's short-circuit, in either direction, over a flag's rollout. */
|
|
240
|
+
export interface PikkuFeatureFlagOverridesTable {
|
|
241
|
+
flag: string;
|
|
242
|
+
subjectId: string;
|
|
243
|
+
subjectKind: string;
|
|
244
|
+
enabled: boolean;
|
|
245
|
+
grantedBy: string | null;
|
|
246
|
+
grantedAt: Generated<Date>;
|
|
247
|
+
}
|
|
225
248
|
export interface PikkuRoleScopesTable {
|
|
226
249
|
role: string;
|
|
227
250
|
scope: string;
|
|
@@ -317,6 +340,25 @@ export interface AuditTable {
|
|
|
317
340
|
old: string | null;
|
|
318
341
|
data: string | null;
|
|
319
342
|
}
|
|
343
|
+
/** One analytics event, as {@link KyselyAnalyticsService} appended it. */
|
|
344
|
+
export interface PikkuAnalyticsEventsTable {
|
|
345
|
+
eventId: string;
|
|
346
|
+
name: string;
|
|
347
|
+
/** ISO 8601, because string ordering is chronological ordering. */
|
|
348
|
+
occurredAt: string;
|
|
349
|
+
source: string;
|
|
350
|
+
functionId: string | null;
|
|
351
|
+
wireType: string | null;
|
|
352
|
+
traceId: string | null;
|
|
353
|
+
userId: string | null;
|
|
354
|
+
orgId: string | null;
|
|
355
|
+
pikkuUserId: string | null;
|
|
356
|
+
/** The device id for a visitor with no session yet. */
|
|
357
|
+
anonymousId: string | null;
|
|
358
|
+
vendorIds: string | null;
|
|
359
|
+
consent: string | null;
|
|
360
|
+
props: string | null;
|
|
361
|
+
}
|
|
320
362
|
/**
|
|
321
363
|
* One turn of a run's transcript.
|
|
322
364
|
*
|
|
@@ -366,6 +408,8 @@ export interface KyselyPikkuDB {
|
|
|
366
408
|
pikkuRoleScopes: PikkuRoleScopesTable;
|
|
367
409
|
pikkuUserRole: PikkuUserRoleTable;
|
|
368
410
|
pikkuUserScope: PikkuUserScopeTable;
|
|
411
|
+
pikkuFeatureFlags: PikkuFeatureFlagsTable;
|
|
412
|
+
pikkuFeatureFlagOverrides: PikkuFeatureFlagOverridesTable;
|
|
369
413
|
channels: ChannelsTable;
|
|
370
414
|
channelSubscriptions: ChannelSubscriptionsTable;
|
|
371
415
|
workflowRuns: WorkflowRunsTable;
|
|
@@ -393,4 +437,5 @@ export interface KyselyPikkuDB {
|
|
|
393
437
|
virtualUserRunStep: VirtualUserRunStepTable;
|
|
394
438
|
virtualUserSchedule: VirtualUserScheduleTable;
|
|
395
439
|
audit: AuditTable;
|
|
440
|
+
pikkuAnalyticsEvents: PikkuAnalyticsEventsTable;
|
|
396
441
|
}
|
|
@@ -15,7 +15,7 @@ export declare class KyselyWebhookService extends QueueWebhookService {
|
|
|
15
15
|
private initialized;
|
|
16
16
|
constructor(queueService: QueueService, db: Kysely<KyselyPikkuDB>);
|
|
17
17
|
init(): Promise<void>;
|
|
18
|
-
send(input: SendWebhookInput): Promise<SendWebhookResult
|
|
18
|
+
send(input: SendWebhookInput): Promise<Required<SendWebhookResult>>;
|
|
19
19
|
recordAttempt(deliveryId: string, { statusCode, responseBody, error, delivered }: WebhookAttemptResult): Promise<void>;
|
|
20
20
|
/** List deliveries, most recent first — for the console webhooks view. */
|
|
21
21
|
listDeliveries(opts?: {
|
|
@@ -37,7 +37,7 @@ export class KyselyWebhookService extends QueueWebhookService {
|
|
|
37
37
|
// deliveryId doubles as the queue jobId (idempotency) and rides in the
|
|
38
38
|
// payload so the worker can record attempts against this row.
|
|
39
39
|
const jobId = await this.queueService.add(PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, { ...jobData, deliveryId }, { ...options, jobId: deliveryId });
|
|
40
|
-
return { jobId };
|
|
40
|
+
return { jobId, deliveryId };
|
|
41
41
|
}
|
|
42
42
|
async recordAttempt(deliveryId, { statusCode, responseBody, error, delivered }) {
|
|
43
43
|
const now = new Date();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PikkuSchema } from './pikku-schema.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The `pikkuAnalyticsEvents` table {@link KyselyAnalyticsService} appends to.
|
|
4
|
+
*
|
|
5
|
+
* Gated by `ownedBy` so it arrives only for a project that declares events: a
|
|
6
|
+
* database that measures nothing should not carry a table for it.
|
|
7
|
+
*
|
|
8
|
+
* Every column is text, and `occurredAt` is an ISO 8601 string rather than a
|
|
9
|
+
* timestamp, for the same reason the audit trail is: a warehouse loader reading
|
|
10
|
+
* these rows has no shared type to agree on, and string ordering of ISO 8601 is
|
|
11
|
+
* chronological ordering.
|
|
12
|
+
*
|
|
13
|
+
* The identity is spread across columns rather than left inside the JSON
|
|
14
|
+
* because it is what every query groups by — one visitor's funnel, one org's
|
|
15
|
+
* usage — while `props` differs per event and is only ever read back whole.
|
|
16
|
+
*/
|
|
17
|
+
export declare const analyticsSchema: PikkuSchema;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `pikkuAnalyticsEvents` table {@link KyselyAnalyticsService} appends to.
|
|
3
|
+
*
|
|
4
|
+
* Gated by `ownedBy` so it arrives only for a project that declares events: a
|
|
5
|
+
* database that measures nothing should not carry a table for it.
|
|
6
|
+
*
|
|
7
|
+
* Every column is text, and `occurredAt` is an ISO 8601 string rather than a
|
|
8
|
+
* timestamp, for the same reason the audit trail is: a warehouse loader reading
|
|
9
|
+
* these rows has no shared type to agree on, and string ordering of ISO 8601 is
|
|
10
|
+
* chronological ordering.
|
|
11
|
+
*
|
|
12
|
+
* The identity is spread across columns rather than left inside the JSON
|
|
13
|
+
* because it is what every query groups by — one visitor's funnel, one org's
|
|
14
|
+
* usage — while `props` differs per event and is only ever read back whole.
|
|
15
|
+
*/
|
|
16
|
+
export const analyticsSchema = {
|
|
17
|
+
name: 'analytics',
|
|
18
|
+
ownedBy: ['analyticsService'],
|
|
19
|
+
statements: [
|
|
20
|
+
(db) => db.schema
|
|
21
|
+
.createTable('pikkuAnalyticsEvents')
|
|
22
|
+
.addColumn('eventId', 'text', (col) => col.primaryKey())
|
|
23
|
+
.addColumn('name', 'text', (col) => col.notNull())
|
|
24
|
+
.addColumn('occurredAt', 'text', (col) => col.notNull())
|
|
25
|
+
.addColumn('source', 'text', (col) => col.notNull())
|
|
26
|
+
.addColumn('functionId', 'text')
|
|
27
|
+
.addColumn('wireType', 'text')
|
|
28
|
+
.addColumn('traceId', 'text')
|
|
29
|
+
.addColumn('userId', 'text')
|
|
30
|
+
.addColumn('orgId', 'text')
|
|
31
|
+
.addColumn('pikkuUserId', 'text')
|
|
32
|
+
// The device id for a visitor with no session. Its own column beside
|
|
33
|
+
// `userId` and not folded into it: the whole point of the anonymous id
|
|
34
|
+
// is the traffic that has no user to be attributed to yet.
|
|
35
|
+
.addColumn('anonymousId', 'text')
|
|
36
|
+
.addColumn('vendorIds', 'text')
|
|
37
|
+
.addColumn('consent', 'text')
|
|
38
|
+
.addColumn('props', 'text'),
|
|
39
|
+
(db) => db.schema
|
|
40
|
+
.createIndex('idx_pikku_analytics_events_occurred_at')
|
|
41
|
+
.on('pikkuAnalyticsEvents')
|
|
42
|
+
.column('occurredAt'),
|
|
43
|
+
(db) => db.schema
|
|
44
|
+
.createIndex('idx_pikku_analytics_events_name')
|
|
45
|
+
.on('pikkuAnalyticsEvents')
|
|
46
|
+
.column('name'),
|
|
47
|
+
],
|
|
48
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PikkuSchema } from './pikku-schema.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Feature flags and their per-subject overrides.
|
|
4
|
+
*
|
|
5
|
+
* Two tables and not one because the two halves have different cardinality: a
|
|
6
|
+
* flag is one row an operator flips, an override is an unbounded set keyed by
|
|
7
|
+
* organization or user. Folding the overrides into a JSON column on the flag
|
|
8
|
+
* would make granting one tenant early access a read-modify-write of a blob two
|
|
9
|
+
* consoles can race on.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here references `user` or any organization table. A subject id is
|
|
12
|
+
* stored as opaque text on purpose: pikku does not own the organization
|
|
13
|
+
* concept, and a foreign key would make flags require a table shape that only
|
|
14
|
+
* some projects have.
|
|
15
|
+
*/
|
|
16
|
+
export declare const flagSchema: PikkuSchema;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
/**
|
|
3
|
+
* Feature flags and their per-subject overrides.
|
|
4
|
+
*
|
|
5
|
+
* Two tables and not one because the two halves have different cardinality: a
|
|
6
|
+
* flag is one row an operator flips, an override is an unbounded set keyed by
|
|
7
|
+
* organization or user. Folding the overrides into a JSON column on the flag
|
|
8
|
+
* would make granting one tenant early access a read-modify-write of a blob two
|
|
9
|
+
* consoles can race on.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here references `user` or any organization table. A subject id is
|
|
12
|
+
* stored as opaque text on purpose: pikku does not own the organization
|
|
13
|
+
* concept, and a foreign key would make flags require a table shape that only
|
|
14
|
+
* some projects have.
|
|
15
|
+
*/
|
|
16
|
+
export const flagSchema = {
|
|
17
|
+
name: 'flag',
|
|
18
|
+
ownedBy: ['featureFlags'],
|
|
19
|
+
statements: [
|
|
20
|
+
(db) => db.schema
|
|
21
|
+
.createTable('pikkuFeatureFlags')
|
|
22
|
+
.addColumn('name', 'text', (col) => col.primaryKey())
|
|
23
|
+
.addColumn('description', 'text')
|
|
24
|
+
// The declared `anyOf` scope list, as JSON. Read by the console to say
|
|
25
|
+
// who would see the feature; never read on the request path, where the
|
|
26
|
+
// capability half comes from the session's own scopes.
|
|
27
|
+
.addColumn('anyOf', 'text')
|
|
28
|
+
// Off until somebody turns it on. A flag exists so a feature can ship
|
|
29
|
+
// dark, so the row a deploy creates has to start closed — the opposite
|
|
30
|
+
// default would make merging the declaration the moment of launch.
|
|
31
|
+
.addColumn('enabled', 'boolean', (col) => col.defaultTo(false).notNull())
|
|
32
|
+
// 0-100, or null for no rollout constraint. Null and 100 are not the
|
|
33
|
+
// same statement: null says the percentage is not part of this flag's
|
|
34
|
+
// story, 100 says a rollout reached everyone and could be wound back.
|
|
35
|
+
.addColumn('rolloutPercent', 'integer')
|
|
36
|
+
// False once the declaration has gone from code: still switchable, no
|
|
37
|
+
// longer offered, awaiting `pikku flags prune`. The same additive
|
|
38
|
+
// contract as `pikkuScopes.declared`.
|
|
39
|
+
.addColumn('declared', 'boolean', (col) => col.defaultTo(true).notNull())
|
|
40
|
+
.addColumn('updatedBy', 'text')
|
|
41
|
+
// Why it was flipped. A kill switch is pulled during an incident, and
|
|
42
|
+
// the one question afterwards is always who turned it off and why.
|
|
43
|
+
.addColumn('note', 'text')
|
|
44
|
+
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
45
|
+
(db) => db.schema
|
|
46
|
+
.createTable('pikkuFeatureFlagOverrides')
|
|
47
|
+
.addColumn('flag', 'text', (col) => col.notNull().references('pikkuFeatureFlags.name').onDelete('cascade'))
|
|
48
|
+
.addColumn('subjectId', 'text', (col) => col.notNull())
|
|
49
|
+
// Which id this is, for the console to render. Resolution keys on the
|
|
50
|
+
// id alone, because that is all the resolver has: it prefers the
|
|
51
|
+
// organization and falls back to the user, and by then the two are one
|
|
52
|
+
// string.
|
|
53
|
+
.addColumn('subjectKind', 'text', (col) => col.notNull())
|
|
54
|
+
.addColumn('enabled', 'boolean', (col) => col.notNull())
|
|
55
|
+
.addColumn('grantedBy', 'text')
|
|
56
|
+
.addColumn('grantedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
57
|
+
.addPrimaryKeyConstraint('pikku_feature_flag_overrides_pk', [
|
|
58
|
+
'flag',
|
|
59
|
+
'subjectId',
|
|
60
|
+
]),
|
|
61
|
+
],
|
|
62
|
+
};
|
|
@@ -18,10 +18,12 @@ export { rawStatement, requiredType, schemaContext, unqualifiedContext, } from '
|
|
|
18
18
|
*/
|
|
19
19
|
export declare const pikkuSchemas: PikkuSchema[];
|
|
20
20
|
export { agentSchema } from './agent.schema.js';
|
|
21
|
+
export { analyticsSchema } from './analytics.schema.js';
|
|
21
22
|
export { auditSchema } from './audit.schema.js';
|
|
22
23
|
export { channelSchema } from './channel.schema.js';
|
|
23
24
|
export { credentialSchema } from './credential.schema.js';
|
|
24
25
|
export { deploymentSchema } from './deployment.schema.js';
|
|
26
|
+
export { flagSchema } from './flag.schema.js';
|
|
25
27
|
export { scopeSchema } from './scope.schema.js';
|
|
26
28
|
export { secretSchema } from './secret.schema.js';
|
|
27
29
|
export { sessionSchema } from './session.schema.js';
|
package/dist/src/schema/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { CamelCasePlugin } from 'kysely';
|
|
2
2
|
import { schemaContext, } from './pikku-schema.types.js';
|
|
3
3
|
import { agentSchema } from './agent.schema.js';
|
|
4
|
+
import { analyticsSchema } from './analytics.schema.js';
|
|
4
5
|
import { auditSchema } from './audit.schema.js';
|
|
5
6
|
import { channelSchema } from './channel.schema.js';
|
|
6
7
|
import { credentialSchema } from './credential.schema.js';
|
|
7
8
|
import { deploymentSchema } from './deployment.schema.js';
|
|
8
9
|
import { scopeSchema } from './scope.schema.js';
|
|
10
|
+
import { flagSchema } from './flag.schema.js';
|
|
9
11
|
import { secretSchema } from './secret.schema.js';
|
|
10
12
|
import { sessionSchema } from './session.schema.js';
|
|
11
13
|
import { webhookSchema } from './webhook.schema.js';
|
|
@@ -37,15 +39,19 @@ export const pikkuSchemas = [
|
|
|
37
39
|
workflowSchema,
|
|
38
40
|
agentSchema,
|
|
39
41
|
scopeSchema,
|
|
42
|
+
flagSchema,
|
|
40
43
|
auditSchema,
|
|
44
|
+
analyticsSchema,
|
|
41
45
|
virtualUserSchema,
|
|
42
46
|
virtualUserScheduleSchema,
|
|
43
47
|
];
|
|
44
48
|
export { agentSchema } from './agent.schema.js';
|
|
49
|
+
export { analyticsSchema } from './analytics.schema.js';
|
|
45
50
|
export { auditSchema } from './audit.schema.js';
|
|
46
51
|
export { channelSchema } from './channel.schema.js';
|
|
47
52
|
export { credentialSchema } from './credential.schema.js';
|
|
48
53
|
export { deploymentSchema } from './deployment.schema.js';
|
|
54
|
+
export { flagSchema } from './flag.schema.js';
|
|
49
55
|
export { scopeSchema } from './scope.schema.js';
|
|
50
56
|
export { secretSchema } from './secret.schema.js';
|
|
51
57
|
export { sessionSchema } from './session.schema.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/kysely",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.27",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/pikkujs/pikku.git",
|
|
7
|
+
"directory": "packages/services/kysely"
|
|
8
|
+
},
|
|
4
9
|
"description": "Kysely-backed Pikku services — workflow state, secrets, sessions, scopes and audit",
|
|
5
10
|
"author": "yasser.fadl@gmail.com",
|
|
6
11
|
"license": "MIT",
|
|
@@ -18,23 +23,23 @@
|
|
|
18
23
|
"test:watch": "bash run-tests.sh --watch",
|
|
19
24
|
"test:coverage": "bash run-tests.sh --coverage",
|
|
20
25
|
"release": "npm run build && npm test",
|
|
21
|
-
"prepublishOnly": "
|
|
26
|
+
"prepublishOnly": "bun run build"
|
|
22
27
|
},
|
|
23
28
|
"peerDependencies": {
|
|
24
|
-
"@pikku/core": "^0.12.
|
|
29
|
+
"@pikku/core": "^0.12.111"
|
|
25
30
|
},
|
|
26
31
|
"dependencies": {
|
|
27
32
|
"kysely": "^0.29.5"
|
|
28
33
|
},
|
|
29
34
|
"devDependencies": {
|
|
35
|
+
"@electric-sql/pglite": "^0.5.5",
|
|
30
36
|
"@types/better-sqlite3": "^9.6.0",
|
|
31
37
|
"better-sqlite3": "^13.0.3",
|
|
32
38
|
"kysely-codegen": "^0.20.0",
|
|
33
39
|
"kysely-postgres-js": "^3.0.0",
|
|
34
|
-
"postgres": "^3.4.9"
|
|
35
|
-
"typescript": "^6.0.3"
|
|
40
|
+
"postgres": "^3.4.9"
|
|
36
41
|
},
|
|
37
42
|
"exports": {
|
|
38
43
|
".": "./dist/src/index.js"
|
|
39
44
|
}
|
|
40
|
-
}
|
|
45
|
+
}
|
package/run-tests.sh
CHANGED
|
@@ -34,10 +34,17 @@ files=($(find src -type f -name "*.test.ts"))
|
|
|
34
34
|
# Check if any files matched the pattern
|
|
35
35
|
if [ ${#files[@]} -eq 0 ]; then
|
|
36
36
|
echo "No test files found matching pattern: $pattern"
|
|
37
|
+
if [ "${STRICT_TEST_DISCOVERY}" = "1" ]; then
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
37
40
|
exit 0
|
|
38
41
|
fi
|
|
39
42
|
|
|
40
43
|
# Construct the node command
|
|
44
|
+
#
|
|
45
|
+
# Every other package runs its tests on bun. This one stays on node because
|
|
46
|
+
# better-sqlite3, which every one of these suites opens its fixture database
|
|
47
|
+
# with, aborts the bun process on load.
|
|
41
48
|
node_cmd=(node --import tsx --test)
|
|
42
49
|
|
|
43
50
|
# Append options based on flags
|
|
@@ -47,7 +54,8 @@ fi
|
|
|
47
54
|
|
|
48
55
|
if [ "$coverage_mode" = true ]; then
|
|
49
56
|
node_cmd+=(--test-coverage-include="src/**/*.{ts,js}" --test-coverage-exclude="**/dist/**" --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info)
|
|
57
|
+
export PIKKU_TEST_COVERAGE=1
|
|
50
58
|
fi
|
|
51
59
|
|
|
52
60
|
# Execute the node command with the expanded list of files
|
|
53
|
-
"${node_cmd[@]}" "${files[@]}"
|
|
61
|
+
"${node_cmd[@]}" "${files[@]}"
|
package/bin/pikku-kysely-pure.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { mkdir, readFile, writeFile } from 'fs/promises'
|
|
4
|
-
import { dirname } from 'path'
|
|
5
|
-
import { fileURLToPath } from 'url'
|
|
6
|
-
|
|
7
|
-
const __filename = fileURLToPath(import.meta.url)
|
|
8
|
-
const __dirname = dirname(__filename)
|
|
9
|
-
|
|
10
|
-
const main = async (output: string, input?: string) => {
|
|
11
|
-
let execDirectory = process.cwd()
|
|
12
|
-
let inputPath = `${__dirname}/../node_modules/kysely-codegen/dist/db.d.ts`
|
|
13
|
-
if (input) {
|
|
14
|
-
inputPath = `${execDirectory}/${input}`
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
let kysely: string
|
|
18
|
-
try {
|
|
19
|
-
kysely = await readFile(inputPath, 'utf8')
|
|
20
|
-
} catch {
|
|
21
|
-
console.error(`Could not read file ${input}`)
|
|
22
|
-
process.exit(1)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
kysely = kysely
|
|
26
|
-
.replace('import type { ColumnType } from "kysely";', '')
|
|
27
|
-
.replace(
|
|
28
|
-
new RegExp('^export\\s+type\\s+Generated<[^>]+>.*?;\\s*$', 'ms'),
|
|
29
|
-
''
|
|
30
|
-
)
|
|
31
|
-
.replace(/Generated<(.*)>/g, '$1')
|
|
32
|
-
.replace(
|
|
33
|
-
/export type Numeric =.*;/,
|
|
34
|
-
'export type Numeric = string | number'
|
|
35
|
-
)
|
|
36
|
-
.replace(/export type Timestamp =.*;/, 'export type Timestamp = Date')
|
|
37
|
-
.replace(
|
|
38
|
-
/export type ArrayType<T> = ArrayTypeImpl<T> extends \(infer U\)\[\]\s*\?\s*U\[\]\s*:\s*ArrayTypeImpl<T>;\s*export type ArrayTypeImpl<T> = T extends ColumnType<infer S, infer I, infer U>\s*\?\s*ColumnType<S\[\], I\[\], U\[\]>\s*:\s*T\[\];/gs,
|
|
39
|
-
''
|
|
40
|
-
)
|
|
41
|
-
.replace(/ArrayType<([^>]+)>/g, '$1[]')
|
|
42
|
-
|
|
43
|
-
console.log('Writing kysely-pure.gen.ts')
|
|
44
|
-
|
|
45
|
-
const outputPath = `${execDirectory}/${output}`
|
|
46
|
-
const outputDirectory = dirname(outputPath)
|
|
47
|
-
await mkdir(outputDirectory, { recursive: true })
|
|
48
|
-
await writeFile(outputPath, kysely, 'utf-8')
|
|
49
|
-
console.log(outputPath)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (process.argv.length !== 4) {
|
|
53
|
-
console.error('Expected the following usage:')
|
|
54
|
-
console.error('node kysely-pure.js <output> <input>')
|
|
55
|
-
process.exit(1)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const [, , output, input] = process.argv
|
|
59
|
-
main(output!, input)
|