@pipeline-builder/pipeline-data 0.0.1

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.
Files changed (48) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +61 -0
  3. package/drizzle.config.ts +17 -0
  4. package/lib/api/access-control-builder.d.ts +148 -0
  5. package/lib/api/access-control-builder.js +199 -0
  6. package/lib/api/crud-service.d.ts +343 -0
  7. package/lib/api/crud-service.js +849 -0
  8. package/lib/api/query-builders.d.ts +82 -0
  9. package/lib/api/query-builders.js +460 -0
  10. package/lib/api/reporting-service.d.ts +685 -0
  11. package/lib/api/reporting-service.js +1564 -0
  12. package/lib/api/soft-delete-sweep.d.ts +65 -0
  13. package/lib/api/soft-delete-sweep.js +124 -0
  14. package/lib/api/viewer-context.d.ts +29 -0
  15. package/lib/api/viewer-context.js +63 -0
  16. package/lib/core/query-filters.d.ts +263 -0
  17. package/lib/core/query-filters.js +4 -0
  18. package/lib/database/drizzle-schema.d.ts +7357 -0
  19. package/lib/database/drizzle-schema.js +66 -0
  20. package/lib/database/index.d.ts +5 -0
  21. package/lib/database/index.js +8 -0
  22. package/lib/database/migrator.d.ts +20 -0
  23. package/lib/database/migrator.js +80 -0
  24. package/lib/database/postgres-connection.d.ts +201 -0
  25. package/lib/database/postgres-connection.js +413 -0
  26. package/lib/database/retry-strategy.d.ts +68 -0
  27. package/lib/database/retry-strategy.js +122 -0
  28. package/lib/database/schema/alert.d.ts +588 -0
  29. package/lib/database/schema/alert.js +130 -0
  30. package/lib/database/schema/compliance.d.ts +3319 -0
  31. package/lib/database/schema/compliance.js +377 -0
  32. package/lib/database/schema/dashboard.d.ts +474 -0
  33. package/lib/database/schema/dashboard.js +129 -0
  34. package/lib/database/schema/message-attachment.d.ts +183 -0
  35. package/lib/database/schema/message-attachment.js +40 -0
  36. package/lib/database/schema/message.d.ts +411 -0
  37. package/lib/database/schema/message.js +114 -0
  38. package/lib/database/schema/pipeline-template.d.ts +466 -0
  39. package/lib/database/schema/pipeline-template.js +80 -0
  40. package/lib/database/schema/pipeline.d.ts +1613 -0
  41. package/lib/database/schema/pipeline.js +332 -0
  42. package/lib/database/schema/plugin.d.ts +749 -0
  43. package/lib/database/schema/plugin.js +166 -0
  44. package/lib/database/tenancy.d.ts +103 -0
  45. package/lib/database/tenancy.js +167 -0
  46. package/lib/index.d.ts +32 -0
  47. package/lib/index.js +38 -0
  48. package/package.json +161 -0
@@ -0,0 +1,343 @@
1
+ import { SQL } from 'drizzle-orm';
2
+ import type { AnyColumn } from 'drizzle-orm/column';
3
+ import type { PgTable } from 'drizzle-orm/pg-core';
4
+ import { withTenantTx } from '../database/tenancy.js';
5
+ /**
6
+ * Cast Drizzle query results to a typed array.
7
+ * Drizzle's generic return type (`PgSelectBase<...>`) doesn't narrow to our
8
+ * entity generics, so an explicit cast is needed. Centralised here so every
9
+ * call-site stays one-liner clean and the cast is documented in one place.
10
+ */
11
+ export declare function drizzleRows<T>(rows: unknown): T[];
12
+ /** Cast a Drizzle aggregate result to extract `[{ count: number }]`. */
13
+ export declare function drizzleCount(rows: unknown): [{
14
+ count: number;
15
+ }];
16
+ /** The Drizzle transaction object handed to `withTenantTx` callbacks. Passed to
17
+ * `onBeforePurge` so a subclass's dependent teardown runs in the SAME purge
18
+ * transaction (atomic with the parent DELETE), not a fresh connection. */
19
+ export type CrudTx = Parameters<Parameters<typeof withTenantTx>[0]>[0];
20
+ /**
21
+ * Base interface for entities with common fields
22
+ */
23
+ export interface BaseEntity {
24
+ id: string;
25
+ orgId: string;
26
+ isDefault: boolean;
27
+ createdAt: Date;
28
+ updatedAt: Date;
29
+ createdBy: string;
30
+ updatedBy: string;
31
+ [key: string]: unknown;
32
+ }
33
+ /**
34
+ * Pagination and sorting options
35
+ */
36
+ export interface QueryOptions {
37
+ limit?: number;
38
+ offset?: number;
39
+ sortBy?: string;
40
+ sortOrder?: 'asc' | 'desc';
41
+ /** When true, runs a separate COUNT(*) query to include exact total. Default: false. */
42
+ includeTotal?: boolean;
43
+ /** Cursor-based pagination: fetch rows after this cursor value (uses sortBy column). */
44
+ cursor?: string;
45
+ /** Sparse fieldset: column names to select. Returns all columns when omitted. */
46
+ fields?: string[];
47
+ }
48
+ /**
49
+ * Paginated result with metadata
50
+ */
51
+ export interface PaginatedResult<T> {
52
+ data: T[];
53
+ /** Total count of matching entities. Only present when includeTotal is true. */
54
+ total?: number;
55
+ limit: number;
56
+ offset: number;
57
+ hasMore: boolean;
58
+ /** Cursor pointing to the last item, for cursor-based pagination. */
59
+ nextCursor?: string;
60
+ }
61
+ /** The shared soft-delete retention window in ms (`SOFT_DELETE_RETENTION_DAYS`,
62
+ * default 30d; 0 disables purge-deadline stamping). Exposed so non-CrudService
63
+ * soft-delete paths (e.g. platform's hand-rolled dashboard/alert services)
64
+ * stamp `purge_after` with the SAME window the CrudService entities use. */
65
+ export declare function softDeleteRetentionMs(): number;
66
+ export declare abstract class CrudService<TEntity extends BaseEntity, TFilter, TInsert, TUpdate> {
67
+ /** Drizzle schema table for this entity */
68
+ protected abstract get schema(): PgTable;
69
+ /**
70
+ * Typed column view of {@link schema}. `PgTable` exposes no static columns, so
71
+ * the base class previously reached for `(this.schema as any).id` etc. at each
72
+ * use; centralize that single unavoidable cast here so callers get structured,
73
+ * typed access instead. `visibility` is optional (not every entity has it)
74
+ * and the index signature covers dynamic sparse-fieldset column lookup.
75
+ */
76
+ private get cols();
77
+ /**
78
+ * Build SQL conditions for filtering entities.
79
+ * `parentOrgId` (org → team hierarchy) is optional and only honored by
80
+ * services that opt into parent-inherited visibility (e.g. plugins).
81
+ */
82
+ protected abstract buildConditions(filter: Partial<TFilter>, orgId?: string, parentOrgId?: string): SQL[];
83
+ /** Get the schema column for sorting by field name */
84
+ protected abstract getSortColumn(sortBy: string): AnyColumn | null;
85
+ /** Get the project column for setDefault scoping (null if entity has no project scope) */
86
+ protected abstract getProjectColumn(): AnyColumn | null;
87
+ /**
88
+ * Get the tenant org-id column. Used both to scope `setDefault` and to pin
89
+ * every write (update/delete/bulkDelete) to the caller's own org — so it MUST
90
+ * be the tenant `orgId` column, not a display/name column.
91
+ */
92
+ protected abstract getOrgColumn(): AnyColumn;
93
+ /** Get the unique constraint columns for onConflictDoUpdate */
94
+ protected abstract get conflictTarget(): AnyColumn[];
95
+ private readonly _logger;
96
+ constructor();
97
+ /** Build conditions for a single entity by ID.
98
+ * `parentOrgId` widens visibility to a parent org's public rows (org → team
99
+ * hierarchy) — same opt-in semantics as `find`/`findPaginated`. Omitted by the
100
+ * write path (`writeConditions`), which must stay own-org scoped. */
101
+ private idConditions;
102
+ /**
103
+ * Conditions for a MUTATION (update/delete) of a single entity.
104
+ *
105
+ * `idConditions` reuses the READ access-control clause, which also matches
106
+ * system-org / other-org PUBLIC records — so without an extra ownership pin any
107
+ * tenant could update or soft-delete another org's (or the system org's) public
108
+ * records. AND a strict `orgId === caller` predicate: the read clause's
109
+ * public-OR branch requires `org_id = 'system'`, which then can't be satisfied,
110
+ * leaving only own-org rows. orgId-less (sysadmin) context keeps full access.
111
+ */
112
+ private writeConditions;
113
+ /** Exact id equality, lower-cased to match `buildIdFilter`'s full-UUID branch.
114
+ * `schema` is typed `PgTable` (no static `.id`), so cast — same pattern the
115
+ * sort/default helpers use for column access. */
116
+ private exactIdCondition;
117
+ /** Called after a new entity is created */
118
+ protected onAfterCreate(_entity: TEntity, _userId: string): Promise<void>;
119
+ /** Called after an entity is updated */
120
+ protected onAfterUpdate(_id: string, _entity: TEntity, _userId: string): Promise<void>;
121
+ /** Called after an entity is soft-deleted */
122
+ protected onAfterDelete(_id: string, _entity: TEntity, _userId: string): Promise<void>;
123
+ /** Called after a soft-deleted entity is restored (undo the delete). Override
124
+ * for entities whose restore has side-effects (e.g. re-notify). Best-effort. */
125
+ protected onAfterRestore(_id: string, _entity: TEntity, _userId: string): Promise<void>;
126
+ /** Called with the ids about to be hard-purged, BEFORE the delete, inside the
127
+ * purge transaction (`tx`) — a subclass tearing down dependents that lack ON
128
+ * DELETE CASCADE MUST use `tx` so the teardown is atomic with the parent
129
+ * DELETE (and can't self-deadlock against its row locks). Throwing aborts that
130
+ * batch's purge (rows stay tombstoned, retried next tick). */
131
+ protected onBeforePurge(_ids: string[], _tx: CrudTx): Promise<void>;
132
+ /** Called after rows are hard-purged, for external side-effects (e.g. plugin
133
+ * image GC). Best-effort: errors are logged, never block the sweep. */
134
+ protected onAfterPurge(_ids: string[]): Promise<void>;
135
+ /** Retention before a soft-deleted row becomes purge-eligible. Defaults to the
136
+ * shared `SOFT_DELETE_RETENTION_DAYS`; override per entity for a bespoke window. */
137
+ protected get softDeleteRetentionMs(): number;
138
+ /** The `purge_after` value to stamp on a soft-delete: `now + retention`, or the
139
+ * spread-friendly empty object when the entity has no `purge_after` column.
140
+ * `protected` so subclasses with hand-rolled soft-delete paths (e.g.
141
+ * message-service's thread cascade / sysadmin moderation) stamp it too — an
142
+ * un-stamped tombstone has NULL `purge_after` and is never hard-purged. */
143
+ protected purgeAfterStamp(now: Date): Record<string, unknown>;
144
+ /**
145
+ * Org → team hierarchy: when a read is widened to a parent org (`parentOrgId`
146
+ * set), the parent's rows are outside the caller's RLS scope, so the read must
147
+ * run under sysadmin context — the access-control WHERE clause (own + parent +
148
+ * system public, built server-side from a trusted JWT claim) is then the
149
+ * authoritative tenancy gate. Without `parentOrgId` it stays RLS-scoped.
150
+ *
151
+ * INVARIANT (why the sysadmin bypass is safe): the WHERE clause is always
152
+ * server-built by `buildConditions(filter, orgId, parentOrgId)` from values
153
+ * derived from the validated JWT (`orgId` = active org, `parentOrgId` = the
154
+ * active org's parent). None of them is a raw user-supplied predicate, so the
155
+ * bypass only ever widens the read to the caller's own subtree + system public
156
+ * rows — it can NOT be steered at an arbitrary org. NOTE: this does remove the
157
+ * RLS backstop for the widened read; a single-GUC `app.org_id` can't express
158
+ * the "own OR parent OR system" set the policy would need, so replacing the
159
+ * bypass would require a tenancy-layer change (e.g. a multi-org RLS policy).
160
+ * Deferred deliberately — do not swap this for a blanket bypass without
161
+ * re-checking the WHERE is still fully server-built.
162
+ */
163
+ private runRead;
164
+ /**
165
+ * Find entities matching filter criteria
166
+ *
167
+ * @param filter - Filter criteria
168
+ * @param orgId - User's organization ID (optional — omit for anonymous/system-public-only access)
169
+ */
170
+ find(filter: Partial<TFilter>, orgId?: string, parentOrgId?: string): Promise<TEntity[]>;
171
+ /**
172
+ * Find the FIRST entity matching `filter` — a bounded (`LIMIT 1`) variant of
173
+ * `find` for the "match by filter, take one" read paths (e.g. `GET /find`), so
174
+ * they don't `SELECT` every matching org row just to return `[0]`.
175
+ */
176
+ findFirst(filter: Partial<TFilter>, orgId?: string, parentOrgId?: string): Promise<TEntity | null>;
177
+ /**
178
+ * Find entities with pagination and sorting
179
+ *
180
+ * @param filter - Filter criteria
181
+ * @param orgId - User's organization ID (optional — omit for anonymous/system-public-only access)
182
+ * @param options - Pagination and sorting options
183
+ */
184
+ findPaginated(filter: Partial<TFilter>, orgId?: string, options?: QueryOptions, parentOrgId?: string): Promise<PaginatedResult<TEntity>>;
185
+ /**
186
+ * Build a column selection map for sparse fieldsets.
187
+ * Falls back to full select if no matching columns found.
188
+ *
189
+ * `sortBy` (when set) is always folded into the projection even if the caller's
190
+ * `fields` omits it — cursor pagination reads the next cursor from the sort
191
+ * column's value on the last row, so dropping it from the SELECT would yield an
192
+ * undefined cursor and stall paging. Included the same way `id` always is.
193
+ */
194
+ private buildFieldSelect;
195
+ /**
196
+ * Count entities matching filter criteria
197
+ *
198
+ * @param filter - Filter criteria
199
+ * @param orgId - User's organization ID (optional — omit for anonymous/system-public-only access)
200
+ * @param parentOrgId - Org → team hierarchy: widen the count to the parent org's
201
+ * public rows. MUST mirror `find`/`findPaginated`'s widening (same
202
+ * `buildConditions` args + sysadmin-scoped read via `runRead`) so a paged
203
+ * list's `total` matches the rows a widened `find()` returns — otherwise the
204
+ * count omits the parent's rows and reports a narrower total than the data set.
205
+ */
206
+ count(filter: Partial<TFilter>, orgId?: string, parentOrgId?: string): Promise<number>;
207
+ /**
208
+ * Find a single entity by ID
209
+ *
210
+ * @param id - Entity ID
211
+ * @param orgId - User's organization ID (optional — omit for anonymous/system-public-only access)
212
+ * @param parentOrgId - Org → team hierarchy: widen visibility to the parent
213
+ * org's public rows (so a team can fetch a parent's public entity by id).
214
+ * Same opt-in + sysadmin-scoped read as `find`/`findPaginated`; the
215
+ * access-control WHERE clause is the authoritative tenancy gate.
216
+ */
217
+ findById(id: string, orgId?: string, parentOrgId?: string): Promise<TEntity | null>;
218
+ /**
219
+ * Defense-in-depth tenant stamp. RLS is in owner-bypass mode, so the app layer
220
+ * is the only tenant gate on writes. A NON-sysadmin caller may only write into
221
+ * its OWN org, so pin the row to the trusted tenant-context org.
222
+ *
223
+ * The stamp fires whenever the payload's `orgId` differs from the context org —
224
+ * which covers BOTH a forged/mismatched `data.orgId` AND an ABSENT one. The
225
+ * absent case is the important tenant-boundary fix: the `org_id` column DEFAULTs
226
+ * to `SYSTEM_ORG_ID` (schema/pipeline.ts, schema/plugin.ts), so an insert that
227
+ * omits `orgId` would otherwise land the tenant's row in the public system
228
+ * catalog — a cross-tenant fail-open. Always injecting `ctx.orgId` closes it.
229
+ *
230
+ * Sysadmin (and out-of-context worker/system paths, where `getTenantContext()`
231
+ * is undefined) keep the supplied org, matching the RLS policy
232
+ * `current_is_sysadmin() OR org_id = current_org_id()`.
233
+ */
234
+ protected enforceOrgId<T>(data: T, isCreate?: boolean): T;
235
+ /**
236
+ * Create a new entity
237
+ */
238
+ create(data: TInsert, userId: string): Promise<TEntity>;
239
+ /**
240
+ * Update an existing entity
241
+ */
242
+ update(id: string, data: Partial<TUpdate>, orgId: string, userId: string): Promise<TEntity | null>;
243
+ /**
244
+ * Delete an entity (soft delete by setting isActive = false)
245
+ */
246
+ delete(id: string, orgId: string, userId: string): Promise<TEntity | null>;
247
+ /**
248
+ * Set an entity as the default for a project/organization scope.
249
+ * Marks all other entities as non-default, then sets the specified entity.
250
+ * Uses a transaction to ensure atomicity.
251
+ */
252
+ setDefault(project: string, org: string, id: string, userId: string): Promise<TEntity>;
253
+ /**
254
+ * Update multiple entities matching filter
255
+ */
256
+ updateMany(filter: Partial<TFilter>, data: Partial<TUpdate>, orgId: string, userId: string): Promise<TEntity[]>;
257
+ /**
258
+ * Create multiple entities in a single batch insert.
259
+ * Uses upsert (onConflictDoUpdate) — all rows are inserted in one query per chunk.
260
+ * Chunks of 100 to stay within PostgreSQL parameter limits.
261
+ */
262
+ /**
263
+ * Build the `onConflictDoUpdate` SET for a bulk upsert. Unlike a single-row
264
+ * create (which can spread the one row's data), a batched insert has many rows,
265
+ * so each updatable column is written from `excluded.*` (the would-be-inserted
266
+ * value) — otherwise an existing row's fields are silently discarded and only
267
+ * timestamps bump. Skips identity/immutable (`id`, `createdAt`, `createdBy`)
268
+ * and the conflict-target columns; `updatedAt`/`updatedBy` get the fresh values.
269
+ */
270
+ private buildBulkUpsertSet;
271
+ bulkCreate(items: TInsert[], userId: string): Promise<TEntity[]>;
272
+ /**
273
+ * Soft-delete multiple entities by IDs in a single batch operation.
274
+ */
275
+ bulkDelete(ids: string[], orgId: string, userId: string,
276
+ /** When true, only PRIVATE records are deleted — mirrors the single-delete
277
+ * `requirePublicAccess` gate so a non-sysadmin can't bulk-delete public
278
+ * (shared/sysadmin-managed) records. No-op for entities without an
279
+ * `visibility` column. Callers pass `!isSystemAdmin(req)`. */
280
+ restrictToPrivate?: boolean): Promise<TEntity[]>;
281
+ /**
282
+ * Restore a soft-deleted (tombstoned) entity: clear `isActive`/`deletedAt`/
283
+ * `deletedBy`/`purgeAfter` so it reappears in normal reads.
284
+ *
285
+ * Matches ONLY a genuine tombstone — `isActive = false` AND `deletedAt IS NOT
286
+ * NULL` — so a merely *deactivated* row (isActive=false, no deletedAt) is never
287
+ * silently "restored". Pinned to the caller's own org (orgId-less sysadmin
288
+ * context spans orgs, matching delete/update). Returns null when there is no
289
+ * such tombstone (already active, purged, or unknown) so the route can 404.
290
+ * A unique-key collision (a live row already holds the key) surfaces as the
291
+ * driver's unique-violation for the route to map to 409 — in practice the
292
+ * tombstone IS the key holder, so this is defensive.
293
+ */
294
+ /** Conditions matching a GENUINE tombstone (`isActive=false` AND `deletedAt IS
295
+ * NOT NULL`), optionally pinned to `id` and/or `orgId`. Single source of truth
296
+ * for restore/findDeletedById/findDeleted so the "what is a tombstone" rule
297
+ * lives in one place. */
298
+ private tombstoneConditions;
299
+ restore(id: string, orgId: string, userId: string): Promise<TEntity | null>;
300
+ /**
301
+ * Read a soft-deleted entity by id (the inverse of the default `isActive=true`
302
+ * reads) — used by restore routes to load the tombstone for access-control
303
+ * gating before restoring. Returns null unless the row exists AND is a genuine
304
+ * tombstone (`isActive=false` + `deletedAt IS NOT NULL`).
305
+ */
306
+ findDeletedById(id: string, orgId?: string): Promise<TEntity | null>;
307
+ /**
308
+ * List an org's soft-deleted tombstones (`isActive=false` + `deletedAt IS NOT
309
+ * NULL`), most-recently-deleted first — powers the "recently deleted" restore
310
+ * UI. Returns `[]` for entities without a soft-delete lifecycle. `limit` is
311
+ * clamped to [1, 200] to keep the response bounded (the retention sweep hard-
312
+ * deletes old tombstones anyway, so this is a short, self-limiting list).
313
+ */
314
+ findDeleted(orgId: string, opts?: {
315
+ limit?: number;
316
+ offset?: number;
317
+ }): Promise<TEntity[]>;
318
+ /**
319
+ * Hard-delete a batch of tombstones whose purge deadline has passed
320
+ * (`deletedAt IS NOT NULL AND purge_after < now`). Batched (`limit`) so a
321
+ * sweep tick stays bounded; the per-service sweep loops until a tick returns
322
+ * `< limit`. Runs across ALL orgs, so callers MUST invoke it from a sysadmin
323
+ * scope (`runSoftDeletePurge` establishes one). `onBeforePurge(ids, tx)` tears
324
+ * down dependents lacking ON DELETE CASCADE inside the same tx; `onAfterPurge`
325
+ * handles external side-effects.
326
+ * Returns the number of rows purged. No-op (0) for entities without the columns.
327
+ */
328
+ purgeExpired(now: Date, limit?: number): Promise<number>;
329
+ /**
330
+ * On-demand hard-delete of a SINGLE tombstone by id — the manual counterpart
331
+ * to the retention sweep's `purgeExpired`. Permanently removes the row ONLY
332
+ * when it is a genuine tombstone (`isActive=false` + `deletedAt IS NOT NULL`),
333
+ * optionally pinned to `orgId` (an orgId-less sysadmin context spans orgs,
334
+ * matching restore/delete). Runs the same `onBeforePurge` dependent teardown
335
+ * inside the tx and the same `onAfterPurge` side-effects as the sweep.
336
+ *
337
+ * Ignores `purgeAfter`: a manual purge finalizes an already-soft-deleted item
338
+ * immediately rather than waiting for the retention deadline. Returns the
339
+ * purged id, or null when there is no such tombstone (already active, purged,
340
+ * out of scope, or unknown) so the route can 404.
341
+ */
342
+ purgeById(id: string, orgId?: string): Promise<string | null>;
343
+ }