deepline 0.3.90 → 0.3.91

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 (87) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +101 -6
  2. package/dist/bundling-sources/sdk/src/monitor-fleet-contract.ts +2 -556
  3. package/dist/bundling-sources/sdk/src/monitor-fleets.ts +2 -2
  4. package/dist/bundling-sources/sdk/src/release.ts +7 -12
  5. package/dist/bundling-sources/sdk/src/types.ts +6 -1
  6. package/dist/bundling-sources/shared_libs/integration-contracts/monitor-fleet-contract.ts +549 -0
  7. package/dist/bundling-sources/shared_libs/monitors/fleets/errors.ts +96 -0
  8. package/dist/bundling-sources/shared_libs/monitors/fleets/limits.ts +8 -0
  9. package/dist/bundling-sources/shared_libs/monitors/fleets/plan.ts +320 -0
  10. package/dist/bundling-sources/shared_libs/monitors/fleets/verdict.ts +106 -0
  11. package/dist/bundling-sources/shared_libs/monitors/http-error-response.ts +5 -1
  12. package/dist/bundling-sources/shared_libs/monitors/monitor-fleet-limits.ts +2 -12
  13. package/dist/bundling-sources/shared_libs/monitors/package.json +107 -5
  14. package/dist/bundling-sources/shared_libs/monitors/provider-monitor-batch-operation.ts +12 -7
  15. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +36 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +1 -1
  17. package/dist/bundling-sources/shared_libs/play-runtime/db-session-lease-store.ts +578 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-execution-relay.ts +521 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-receipt-gateway.ts +1378 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/runtime-topology.ts +1024 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-gateway-route-probe.ts +193 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machine-lifecycle.ts +104 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machines.ts +1844 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +21 -0
  25. package/dist/bundling-sources/shared_libs/play-runtime/deployment/process-tree.ts +40 -0
  26. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-gateway-drain.ts +262 -0
  27. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-surface.ts +98 -0
  28. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-topology.ts +303 -0
  29. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-publication-guardian.ts +70 -0
  30. package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-recovery.ts +519 -0
  31. package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-fly-rollback-alignment.ts +280 -0
  32. package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-production-gate.ts +159 -0
  33. package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/memory-store.ts +114 -0
  34. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +44 -5
  35. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/child-play-resolver.ts +26 -0
  36. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +3662 -0
  37. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/progress-sidecar.ts +47 -0
  38. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-deadline.ts +84 -0
  39. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-gateway-transport.ts +91 -0
  40. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-heartbeat.ts +424 -0
  41. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-progress-push.ts +321 -0
  42. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-sheet-persistence-retry.ts +110 -0
  43. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-terminal-push.ts +625 -0
  44. package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +46 -3
  45. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-gateway.ts +1 -1
  46. package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +50 -0
  47. package/dist/bundling-sources/shared_libs/plays/bundling/repository-adapter.ts +24 -0
  48. package/dist/bundling-sources/shared_libs/plays/child-run-projection.ts +174 -0
  49. package/dist/bundling-sources/shared_libs/plays/deploy/api-client.ts +267 -0
  50. package/dist/bundling-sources/shared_libs/plays/deploy/control-plane-types.ts +73 -0
  51. package/dist/bundling-sources/shared_libs/plays/deploy/manifest.ts +162 -0
  52. package/dist/bundling-sources/shared_libs/plays/deploy/project-serializer.ts +87 -0
  53. package/dist/bundling-sources/shared_libs/plays/deploy/source-file-path.ts +26 -0
  54. package/dist/bundling-sources/shared_libs/plays/deploy/target-paths.ts +36 -0
  55. package/dist/bundling-sources/shared_libs/plays/launch/input-contract.ts +400 -0
  56. package/dist/bundling-sources/shared_libs/plays/launch/prepare.ts +96 -0
  57. package/dist/bundling-sources/shared_libs/plays/local-file-discovery.ts +385 -0
  58. package/dist/bundling-sources/shared_libs/plays/play-grep-search.ts +79 -6
  59. package/dist/bundling-sources/shared_libs/plays/play-reference.ts +117 -0
  60. package/dist/bundling-sources/shared_libs/plays/run-input-summary.ts +260 -0
  61. package/dist/bundling-sources/shared_libs/plays/run-summary-types.ts +49 -0
  62. package/dist/bundling-sources/shared_libs/plays/secrets/errors.ts +19 -0
  63. package/dist/bundling-sources/shared_libs/plays/sheet-filter.ts +347 -0
  64. package/dist/bundling-sources/shared_libs/plays/standard-webhooks.ts +117 -0
  65. package/dist/bundling-sources/shared_libs/plays/status-result.ts +388 -0
  66. package/dist/bundling-sources/shared_libs/plays/transform-factor-structure.ts +377 -0
  67. package/dist/bundling-sources/shared_libs/plays/webhook-payload.ts +113 -0
  68. package/dist/cli/index.js +369 -162
  69. package/dist/cli/index.mjs +369 -162
  70. package/dist/{compiler-manifest-BaJfE-5H.d.mts → compiler-manifest-x14KL83E.d.mts} +1 -1
  71. package/dist/{compiler-manifest-BaJfE-5H.d.ts → compiler-manifest-x14KL83E.d.ts} +1 -1
  72. package/dist/index.d.mts +45 -8
  73. package/dist/index.d.ts +45 -8
  74. package/dist/index.js +61 -14
  75. package/dist/index.mjs +61 -14
  76. package/dist/install-integrity.json +54 -2
  77. package/dist/monitor-fleet-contract.d.mts +3 -10
  78. package/dist/monitor-fleet-contract.d.ts +3 -10
  79. package/dist/monitor-fleet-contract.js +2 -0
  80. package/dist/monitor-fleet-contract.mjs +1 -1
  81. package/dist/plays/bundle-play-file.d.mts +2 -2
  82. package/dist/plays/bundle-play-file.d.ts +2 -2
  83. package/dist/release.d.mts +3 -3
  84. package/dist/release.d.ts +3 -3
  85. package/dist/release.js +7 -12
  86. package/dist/release.mjs +7 -12
  87. package/package.json +2 -2
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Display-only, size-bounded projection of a play run's input payload.
3
+ *
4
+ * The full input is intentionally NOT persisted in Convex: it can be up to
5
+ * `PLAY_SUBMIT_INPUT_MAX_BYTES` (1MB) and would bloat the hot-path run-start
6
+ * write (and risk the ~1MB Convex document limit). This builds a tiny summary —
7
+ * top-level keys with scalar values truncated and non-scalar values elided to a
8
+ * compact placeholder — purely so the runs UI can show "what was this called
9
+ * with". It is bounded hard so it only adds negligible bytes to the existing
10
+ * start-row write and never touches latency.
11
+ *
12
+ * Scalar values are passed through the shared secret redaction (key-aware, plus
13
+ * value-pattern/high-entropy heuristics) before truncation, mirroring the run
14
+ * *output* preview path — so an input field like `api_key`/`token`/`password`,
15
+ * or a secret-looking value, is never persisted or rendered back in the UI.
16
+ */
17
+
18
+ import { redactSecretsInCustomerValue } from '../security/secret-redaction';
19
+
20
+ const MAX_KEYS = 24;
21
+ const MAX_STRING_CHARS = 120;
22
+ const MAX_NESTED_VALUE_CHARS = 52;
23
+ const MAX_OBJECT_PARTS = 4;
24
+ const MAX_ARRAY_PARTS = 3;
25
+ const MAX_TOTAL_BYTES = 2_000;
26
+
27
+ const OBJECT_PREVIEW_KEYS = [
28
+ 'full_name',
29
+ 'fullName',
30
+ 'name',
31
+ 'first_name',
32
+ 'firstName',
33
+ 'last_name',
34
+ 'lastName',
35
+ 'title',
36
+ 'role',
37
+ 'company_name',
38
+ 'companyName',
39
+ 'company',
40
+ 'domain',
41
+ 'company_domain',
42
+ 'companyDomain',
43
+ 'email',
44
+ 'work_email',
45
+ 'workEmail',
46
+ 'personal_email',
47
+ 'personalEmail',
48
+ 'linkedin_url',
49
+ 'linkedinUrl',
50
+ 'url',
51
+ 'website',
52
+ 'operation',
53
+ 'changedAt',
54
+ 'changed_at',
55
+ 'createdAt',
56
+ 'created_at',
57
+ ] as const;
58
+
59
+ const LOW_SIGNAL_PREVIEW_KEYS = new Set([
60
+ 'id',
61
+ '_id',
62
+ 'key',
63
+ 'metadata',
64
+ 'raw',
65
+ 'debug',
66
+ 'payload',
67
+ 'headers',
68
+ ]);
69
+
70
+ function truncateString(value: string, maxChars: number): string {
71
+ return value.length > maxChars ? `${value.slice(0, maxChars)}…` : value;
72
+ }
73
+
74
+ function formatPreviewKey(key: string): string {
75
+ return key
76
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
77
+ .replace(/[_-]+/g, ' ')
78
+ .trim()
79
+ .toLowerCase();
80
+ }
81
+
82
+ function scalarSummaryValue(
83
+ value: unknown,
84
+ key: string,
85
+ maxChars: number,
86
+ ): string | number | boolean | null | undefined {
87
+ if (value === null) return null;
88
+ switch (typeof value) {
89
+ case 'string': {
90
+ // Key-aware + value-pattern redaction before truncation, so secrets never
91
+ // land in the persisted summary even when the value is short.
92
+ // Customer-aware redaction: key-aware + explicit credential patterns,
93
+ // WITHOUT the high-entropy heuristic — a long opaque customer id
94
+ // (campaign ids, CRM record ids) must survive the summary.
95
+ const safe = redactSecretsInCustomerValue(value, key);
96
+ return truncateString(safe, maxChars);
97
+ }
98
+ case 'number':
99
+ case 'boolean':
100
+ return value;
101
+ case 'bigint':
102
+ return truncateString(value.toString(), maxChars);
103
+ default:
104
+ return undefined;
105
+ }
106
+ }
107
+
108
+ function formatScalarPreview(value: unknown, key: string): string | null {
109
+ const summarized = scalarSummaryValue(value, key, MAX_NESTED_VALUE_CHARS);
110
+ if (summarized === undefined || summarized === null || summarized === '') {
111
+ return null;
112
+ }
113
+ return String(summarized);
114
+ }
115
+
116
+ function isRecord(value: unknown): value is Record<string, unknown> {
117
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
118
+ }
119
+
120
+ function summarizeArrayValue(value: unknown[], key: string): string {
121
+ if (value.length === 0) return '[0 items]';
122
+
123
+ const parts: string[] = [];
124
+ for (const item of value.slice(0, MAX_ARRAY_PARTS)) {
125
+ const preview = isRecord(item)
126
+ ? summarizeObjectValue(item)
127
+ : Array.isArray(item)
128
+ ? `[${item.length} item${item.length === 1 ? '' : 's'}]`
129
+ : formatScalarPreview(item, key);
130
+ if (preview) parts.push(preview);
131
+ }
132
+
133
+ const suffixCount = value.length - parts.length;
134
+ const suffix =
135
+ suffixCount > 0
136
+ ? ` +${suffixCount} item${suffixCount === 1 ? '' : 's'}`
137
+ : '';
138
+ return parts.length > 0
139
+ ? truncateString(`${parts.join(', ')}${suffix}`, MAX_STRING_CHARS)
140
+ : `[${value.length} item${value.length === 1 ? '' : 's'}]`;
141
+ }
142
+
143
+ function summarizeObjectValue(record: Record<string, unknown>): string {
144
+ const entries = Object.entries(record);
145
+ const fieldCount = entries.length;
146
+ if (fieldCount === 0) return '{}';
147
+
148
+ const parts: string[] = [];
149
+ const usedRootKeys = new Set<string>();
150
+
151
+ const addPart = (path: string, rootKey: string, value: unknown): boolean => {
152
+ if (parts.length >= MAX_OBJECT_PARTS) return false;
153
+ const preview = formatScalarPreview(value, path);
154
+ if (!preview) return false;
155
+ parts.push(`${formatPreviewKey(path)}: ${preview}`);
156
+ usedRootKeys.add(rootKey);
157
+ return true;
158
+ };
159
+
160
+ const addPreferredFromRecord = (
161
+ source: Record<string, unknown>,
162
+ rootKey: string,
163
+ prefix = '',
164
+ ) => {
165
+ for (const preferredKey of OBJECT_PREVIEW_KEYS) {
166
+ if (preferredKey in source) {
167
+ addPart(
168
+ prefix ? `${prefix}.${preferredKey}` : preferredKey,
169
+ rootKey || preferredKey,
170
+ source[preferredKey],
171
+ );
172
+ }
173
+ if (parts.length >= MAX_OBJECT_PARTS) return;
174
+ }
175
+ };
176
+
177
+ addPreferredFromRecord(record, '');
178
+
179
+ for (const [rootKey, value] of entries) {
180
+ if (parts.length >= MAX_OBJECT_PARTS) break;
181
+ if (usedRootKeys.has(rootKey) || rootKey.startsWith('_')) continue;
182
+ if (isRecord(value)) {
183
+ addPreferredFromRecord(value, rootKey, rootKey);
184
+ }
185
+ }
186
+
187
+ for (const [rootKey, value] of entries) {
188
+ if (parts.length >= MAX_OBJECT_PARTS) break;
189
+ if (
190
+ usedRootKeys.has(rootKey) ||
191
+ rootKey.startsWith('_') ||
192
+ LOW_SIGNAL_PREVIEW_KEYS.has(rootKey)
193
+ ) {
194
+ continue;
195
+ }
196
+ if (Array.isArray(value)) {
197
+ const preview = summarizeArrayValue(value, rootKey);
198
+ if (preview) {
199
+ parts.push(`${formatPreviewKey(rootKey)}: ${preview}`);
200
+ usedRootKeys.add(rootKey);
201
+ }
202
+ continue;
203
+ }
204
+ addPart(rootKey, rootKey, value);
205
+ }
206
+
207
+ if (parts.length === 0) {
208
+ return `{${fieldCount} field${fieldCount === 1 ? '' : 's'}}`;
209
+ }
210
+
211
+ const remaining = Math.max(0, fieldCount - usedRootKeys.size);
212
+ const suffix =
213
+ remaining > 0 ? ` +${remaining} field${remaining === 1 ? '' : 's'}` : '';
214
+ return truncateString(`${parts.join(' · ')}${suffix}`, MAX_STRING_CHARS);
215
+ }
216
+
217
+ function summarizeValue(value: unknown, key: string): unknown {
218
+ const scalar = scalarSummaryValue(value, key, MAX_STRING_CHARS);
219
+ if (scalar !== undefined) {
220
+ return scalar;
221
+ }
222
+ if (Array.isArray(value)) {
223
+ return summarizeArrayValue(value, key);
224
+ }
225
+ if (isRecord(value)) {
226
+ return summarizeObjectValue(value);
227
+ }
228
+ // functions, symbols, undefined: not displayable
229
+ return undefined;
230
+ }
231
+
232
+ export function buildPlayRunInputSummary(
233
+ input: unknown,
234
+ ): Record<string, unknown> | undefined {
235
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
236
+ return undefined;
237
+ }
238
+ const entries = Object.entries(input as Record<string, unknown>);
239
+ if (entries.length === 0) return undefined;
240
+
241
+ const summary: Record<string, unknown> = {};
242
+ let count = 0;
243
+ for (const [key, value] of entries) {
244
+ if (count >= MAX_KEYS) {
245
+ const remaining = entries.length - count;
246
+ summary['…'] = `+${remaining} more field${remaining === 1 ? '' : 's'}`;
247
+ break;
248
+ }
249
+ const summarized = summarizeValue(value, key);
250
+ if (summarized === undefined) continue;
251
+ summary[key] = summarized;
252
+ count += 1;
253
+ if (JSON.stringify(summary).length > MAX_TOTAL_BYTES) {
254
+ delete summary[key];
255
+ summary['…'] = 'truncated';
256
+ break;
257
+ }
258
+ }
259
+ return Object.keys(summary).length > 0 ? summary : undefined;
260
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Compact, truncated output preview attached to terminal runs by the runs-list
3
+ * endpoints. Lets the runs drawer render an output table without a blocking
4
+ * per-run detail fetch. Not the full sheet — capped rows/columns.
5
+ */
6
+ export type PlayRunSummaryResultPreview = {
7
+ rows: Record<string, unknown>[];
8
+ stats: Record<string, unknown>;
9
+ tableNamespace: string;
10
+ };
11
+
12
+ export type PlayRunSummaryRecord = {
13
+ _id: string;
14
+ playId: string;
15
+ workflowId: string;
16
+ /** Backend runtime attempt identifier, when the scheduler exposes one. */
17
+ runId?: string;
18
+ playName?: string | null;
19
+ parentRunId?: string | null;
20
+ rootRunId?: string | null;
21
+ status: string;
22
+ createdAt: number;
23
+ startedAt?: number;
24
+ finishedAt?: number;
25
+ updatedAt: number;
26
+ artifactTableNamespace?: string;
27
+ artifactTableNamespaces?: string[];
28
+ error?: string;
29
+ outputPreview?: string;
30
+ billingTotalCredits?: number;
31
+ billingMaxCreditsPerRun?: number | null;
32
+ result?: PlayRunSummaryResultPreview;
33
+ /**
34
+ * Size-bounded projection of the play function's normalized return value.
35
+ * It drives run-list columns without fetching the full terminal result for
36
+ * every polling response. Use the explicit full-run endpoint for the exact
37
+ * return value.
38
+ */
39
+ output?: Record<string, unknown>;
40
+ /** The compact `output` projection omitted values or fields. */
41
+ outputPreviewTruncated?: true;
42
+ /**
43
+ * Display-only, size-bounded projection of the run's launch input (see
44
+ * packages/plays/run-input-summary.ts). Top-level keys with scalar values;
45
+ * large/nested values elided to placeholders. Undefined when the run had no
46
+ * input. NOT the full input payload.
47
+ */
48
+ inputSummary?: Record<string, unknown>;
49
+ };
@@ -0,0 +1,19 @@
1
+ export const PLAY_SECRET_UNAVAILABLE = 'PLAY_SECRET_UNAVAILABLE' as const;
2
+
3
+ export class PlaySecretUnavailableError extends Error {
4
+ readonly code = PLAY_SECRET_UNAVAILABLE;
5
+
6
+ constructor(readonly secretName: string) {
7
+ super(`Secret ${secretName} is not active or does not exist.`);
8
+ this.name = 'PlaySecretUnavailableError';
9
+ }
10
+ }
11
+
12
+ export function playSecretUnavailableErrorFrom(
13
+ error: unknown,
14
+ ): PlaySecretUnavailableError | null {
15
+ const message = error instanceof Error ? error.message : String(error);
16
+ const match =
17
+ /Secret ([A-Z][A-Z0-9_]*) is not active or does not exist\./.exec(message);
18
+ return match ? new PlaySecretUnavailableError(match[1]) : null;
19
+ }
@@ -0,0 +1,347 @@
1
+ // Full-table view basic filters. A tiny, deliberately closed predicate model
2
+ // that compiles mechanically to a parameterized SQL WHERE clause against a
3
+ // runtime sheet's physical (jsonb) user-data columns.
4
+ //
5
+ // This is NOT free-text SQL: operators come from a closed enum, columns must
6
+ // be on a caller-supplied allowlist (the sheet's real columns), and every value
7
+ // is bound as a query parameter. Multiple filters AND together. No OR groups.
8
+ //
9
+ // Sheet user-data columns are stored one-per-column as `jsonb` (a JSON scalar
10
+ // like "completed", 19991, or an object). `col #>> '{}'` extracts the scalar as
11
+ // text; numeric comparisons cast that text to numeric.
12
+
13
+ // ── The operator set, and why it grew ────────────────────────────────────────
14
+ //
15
+ // The filter UI is `src/components/data-table-filter` — the repo's shadcn
16
+ // faceted filter, the same one the customer-db grid and the integrations run
17
+ // table use. Its operator vocabulary per column type is fixed by the package,
18
+ // so this enum has to cover it exactly or a filter the user can build is one
19
+ // the server cannot compile.
20
+ //
21
+ // Everything added here is a single clause with a NOT in it, which is precisely
22
+ // what could not be expressed by composing the operators that were already
23
+ // here: this model AND-joins and has no OR groups, so `is not between` cannot
24
+ // be decomposed into two filters. Where a package operator CAN be decomposed it
25
+ // still gets its own entry rather than a rewrite rule, so the payload on the
26
+ // wire reads the same as the chip the user built.
27
+ //
28
+ // `is_empty` / `not_empty` have no operator in the package and are therefore
29
+ // unreachable from the UI. They stay compilable — the API accepts them and an
30
+ // agent can send them — and adding them to the shared package is a deliberate
31
+ // change that has to update every consumer's compiler at the same time.
32
+ export const SHEET_FILTER_TEXT_OPS = [
33
+ 'equals',
34
+ 'not_equals',
35
+ 'contains',
36
+ 'not_contains',
37
+ 'is_empty',
38
+ 'not_empty',
39
+ ] as const;
40
+
41
+ export const SHEET_FILTER_NUMERIC_OPS = [
42
+ 'gt',
43
+ 'lt',
44
+ 'gte',
45
+ 'lte',
46
+ 'num_equals',
47
+ 'num_not_equals',
48
+ 'between',
49
+ 'not_between',
50
+ ] as const;
51
+
52
+ export const SHEET_FILTER_OPS = [
53
+ ...SHEET_FILTER_TEXT_OPS,
54
+ ...SHEET_FILTER_NUMERIC_OPS,
55
+ ] as const;
56
+
57
+ export type SheetFilterOp = (typeof SHEET_FILTER_OPS)[number];
58
+
59
+ const SHEET_FILTER_OP_SET: ReadonlySet<string> = new Set(SHEET_FILTER_OPS);
60
+ const SHEET_FILTER_NUMERIC_OP_SET: ReadonlySet<string> = new Set(
61
+ SHEET_FILTER_NUMERIC_OPS,
62
+ );
63
+ // Ops that carry no value (the value input is hidden in the UI).
64
+ const SHEET_FILTER_VALUELESS_OP_SET: ReadonlySet<SheetFilterOp> = new Set([
65
+ 'is_empty',
66
+ 'not_empty',
67
+ ]);
68
+
69
+ /** Ops whose `value` is an inclusive `[min, max]` pair rather than a scalar. */
70
+ const SHEET_FILTER_RANGE_OP_SET: ReadonlySet<SheetFilterOp> = new Set([
71
+ 'between',
72
+ 'not_between',
73
+ ]);
74
+
75
+ export function isRangeSheetFilterOp(op: SheetFilterOp): boolean {
76
+ return SHEET_FILTER_RANGE_OP_SET.has(op);
77
+ }
78
+
79
+ export type SheetFilter = {
80
+ column: string;
81
+ op: SheetFilterOp;
82
+ /** A scalar for most ops; an inclusive `[min, max]` pair for the range ops
83
+ * (`between`, `not_between`); absent for the valueless ops. */
84
+ value?: unknown;
85
+ };
86
+
87
+ export type CompiledSheetFilter = {
88
+ /** WHERE-clause body WITHOUT the leading `WHERE` (empty string when no
89
+ * filters). AND-joined; callers combine it with their own conditions. */
90
+ sql: string;
91
+ /** Parameters bound in `$n` order, starting at `startParamIndex`. */
92
+ params: unknown[];
93
+ /** Next free `$n` index after the compiled params. */
94
+ nextParamIndex: number;
95
+ };
96
+
97
+ export class SheetFilterError extends Error {
98
+ readonly code = 'invalid_sheet_filter';
99
+ constructor(message: string) {
100
+ super(message);
101
+ this.name = 'SheetFilterError';
102
+ }
103
+ }
104
+
105
+ export function isSheetFilterOp(value: unknown): value is SheetFilterOp {
106
+ return typeof value === 'string' && SHEET_FILTER_OP_SET.has(value);
107
+ }
108
+
109
+ export function isNumericSheetFilterOp(op: SheetFilterOp): boolean {
110
+ return SHEET_FILTER_NUMERIC_OP_SET.has(op);
111
+ }
112
+
113
+ /**
114
+ * Parse an untrusted `filters` payload (from a query param or request body)
115
+ * into a typed, validated filter list. Throws `SheetFilterError` (→ 400) on any
116
+ * malformed entry so the read path fails loudly rather than silently dropping a
117
+ * predicate.
118
+ */
119
+ export function parseSheetFilters(raw: unknown): SheetFilter[] {
120
+ if (raw == null || raw === '') return [];
121
+ let value = raw;
122
+ if (typeof value === 'string') {
123
+ try {
124
+ value = JSON.parse(value);
125
+ } catch {
126
+ throw new SheetFilterError('filters must be a JSON array');
127
+ }
128
+ }
129
+ if (!Array.isArray(value)) {
130
+ throw new SheetFilterError('filters must be a JSON array');
131
+ }
132
+ return value.map((entry, index) => parseSheetFilterEntry(entry, index));
133
+ }
134
+
135
+ function parseSheetFilterEntry(entry: unknown, index: number): SheetFilter {
136
+ if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) {
137
+ throw new SheetFilterError(`filters[${index}] must be an object`);
138
+ }
139
+ const record = entry as Record<string, unknown>;
140
+ const column = record.column;
141
+ if (typeof column !== 'string' || column.trim() === '') {
142
+ throw new SheetFilterError(
143
+ `filters[${index}].column must be a non-empty string`,
144
+ );
145
+ }
146
+ const op = record.op;
147
+ if (!isSheetFilterOp(op)) {
148
+ throw new SheetFilterError(
149
+ `filters[${index}].op "${String(op)}" is not a supported operator`,
150
+ );
151
+ }
152
+ return { column: column.trim(), op, value: record.value };
153
+ }
154
+
155
+ function coerceFilterText(value: unknown, index: number): string {
156
+ if (typeof value === 'string') return value;
157
+ if (typeof value === 'number' || typeof value === 'boolean') {
158
+ return String(value);
159
+ }
160
+ throw new SheetFilterError(
161
+ `filters[${index}].value must be a string, number, or boolean`,
162
+ );
163
+ }
164
+
165
+ const NUMERIC_SQL_OPERATOR: Record<string, string> = {
166
+ gt: '>',
167
+ lt: '<',
168
+ gte: '>=',
169
+ lte: '<=',
170
+ num_equals: '=',
171
+ num_not_equals: '<>',
172
+ };
173
+
174
+ function coerceFilterRange(value: unknown, index: number): [number, number] {
175
+ if (!Array.isArray(value) || value.length !== 2) {
176
+ throw new SheetFilterError(
177
+ `filters[${index}].value must be a [min, max] pair for a range operator`,
178
+ );
179
+ }
180
+ const min = coerceFilterNumber(value[0], index);
181
+ const max = coerceFilterNumber(value[1], index);
182
+ // Accept either order rather than rejecting it: the filter UI's two number
183
+ // inputs have no opinion about which the user fills first, and a silently
184
+ // empty result is the worse failure here.
185
+ return min <= max ? [min, max] : [max, min];
186
+ }
187
+
188
+ function coerceFilterNumber(value: unknown, index: number): number {
189
+ const parsed =
190
+ typeof value === 'number'
191
+ ? value
192
+ : typeof value === 'string' && value.trim() !== ''
193
+ ? Number(value)
194
+ : NaN;
195
+ if (!Number.isFinite(parsed)) {
196
+ throw new SheetFilterError(
197
+ `filters[${index}].value must be a finite number for the "${'numeric'}" operator`,
198
+ );
199
+ }
200
+ return parsed;
201
+ }
202
+
203
+ // Extract a jsonb column's scalar as text: `"completed"` → completed, `19991`
204
+ // → 19991, an object → its JSON text. The single-arg `#>> '{}'` path returns the
205
+ // whole value as text (Postgres jsonb → text).
206
+ function columnAsText(quotedColumn: string): string {
207
+ return `(${quotedColumn} #>> '{}')`;
208
+ }
209
+
210
+ function quoteIdentifier(value: string): string {
211
+ return `"${value.replace(/"/g, '""')}"`;
212
+ }
213
+
214
+ /**
215
+ * Compile validated filters against an allowlist of real column names into a
216
+ * parameterized WHERE body. Unknown columns and numeric ops on non-numeric
217
+ * columns throw `SheetFilterError` (→ 400).
218
+ *
219
+ * @param numericColumns Columns whose UI type is numeric; numeric ops are only
220
+ * admitted on these. Text ops work on every allowed column.
221
+ */
222
+ export function compileSheetFilterWhere(input: {
223
+ filters: readonly SheetFilter[];
224
+ allowedColumns: ReadonlySet<string>;
225
+ numericColumns?: ReadonlySet<string>;
226
+ startParamIndex?: number;
227
+ }): CompiledSheetFilter {
228
+ const startParamIndex = input.startParamIndex ?? 1;
229
+ const numericColumns = input.numericColumns ?? new Set<string>();
230
+ const clauses: string[] = [];
231
+ const params: unknown[] = [];
232
+ let paramIdx = startParamIndex;
233
+
234
+ input.filters.forEach((filter, index) => {
235
+ if (!input.allowedColumns.has(filter.column)) {
236
+ throw new SheetFilterError(
237
+ `filters[${index}].column "${filter.column}" is not a column of this table`,
238
+ );
239
+ }
240
+ const quoted = quoteIdentifier(filter.column);
241
+ const asText = columnAsText(quoted);
242
+
243
+ if (
244
+ isNumericSheetFilterOp(filter.op) &&
245
+ !numericColumns.has(filter.column)
246
+ ) {
247
+ throw new SheetFilterError(
248
+ `filters[${index}].op "${filter.op}" is only valid on numeric columns; "${filter.column}" is not numeric`,
249
+ );
250
+ }
251
+
252
+ switch (filter.op) {
253
+ case 'equals': {
254
+ const text = coerceFilterText(filter.value, index);
255
+ clauses.push(`${asText} = $${paramIdx}`);
256
+ params.push(text);
257
+ paramIdx += 1;
258
+ break;
259
+ }
260
+ case 'not_equals': {
261
+ const text = coerceFilterText(filter.value, index);
262
+ // NULL-safe: a NULL/absent cell is "not equal" to a concrete value.
263
+ clauses.push(`(${asText} IS DISTINCT FROM $${paramIdx})`);
264
+ params.push(text);
265
+ paramIdx += 1;
266
+ break;
267
+ }
268
+ case 'contains': {
269
+ const text = coerceFilterText(filter.value, index);
270
+ clauses.push(`${asText} ILIKE $${paramIdx}`);
271
+ params.push(`%${escapeLikePattern(text)}%`);
272
+ paramIdx += 1;
273
+ break;
274
+ }
275
+ case 'not_contains': {
276
+ const text = coerceFilterText(filter.value, index);
277
+ // NULL-safe, like `not_equals`: a cell with no value does not contain
278
+ // the needle, and `NOT (NULL ILIKE ...)` is NULL, which filters it out.
279
+ clauses.push(`(${asText} IS NULL OR ${asText} NOT ILIKE $${paramIdx})`);
280
+ params.push(`%${escapeLikePattern(text)}%`);
281
+ paramIdx += 1;
282
+ break;
283
+ }
284
+ case 'is_empty': {
285
+ clauses.push(
286
+ `(${quoted} IS NULL OR ${asText} IS NULL OR ${asText} = '')`,
287
+ );
288
+ break;
289
+ }
290
+ case 'not_empty': {
291
+ clauses.push(
292
+ `(${quoted} IS NOT NULL AND ${asText} IS NOT NULL AND ${asText} <> '')`,
293
+ );
294
+ break;
295
+ }
296
+ case 'gt':
297
+ case 'lt':
298
+ case 'gte':
299
+ case 'lte':
300
+ case 'num_equals':
301
+ case 'num_not_equals': {
302
+ const num = coerceFilterNumber(filter.value, index);
303
+ const sqlOp = NUMERIC_SQL_OPERATOR[filter.op];
304
+ // Guard the numeric cast to jsonb numbers only, so a stray non-numeric
305
+ // cell can never abort the whole query with a cast error.
306
+ clauses.push(
307
+ `(jsonb_typeof(${quoted}) = 'number' AND ${asText}::numeric ${sqlOp} $${paramIdx})`,
308
+ );
309
+ params.push(num);
310
+ paramIdx += 1;
311
+ break;
312
+ }
313
+ case 'between':
314
+ case 'not_between': {
315
+ const [min, max] = coerceFilterRange(filter.value, index);
316
+ // `BETWEEN` is inclusive on both ends, which is what the filter UI's
317
+ // range control means by it. The guard is the same one the scalar
318
+ // comparisons use: a non-numeric cell simply does not match, rather
319
+ // than aborting the query on a cast.
320
+ const body = `${asText}::numeric BETWEEN $${paramIdx} AND $${paramIdx + 1}`;
321
+ clauses.push(
322
+ `(jsonb_typeof(${quoted}) = 'number' AND ${
323
+ filter.op === 'between' ? body : `NOT (${body})`
324
+ })`,
325
+ );
326
+ params.push(min, max);
327
+ paramIdx += 2;
328
+ break;
329
+ }
330
+ }
331
+ });
332
+
333
+ return {
334
+ sql: clauses.join(' AND '),
335
+ params,
336
+ nextParamIndex: paramIdx,
337
+ };
338
+ }
339
+
340
+ // Escape LIKE/ILIKE metacharacters so a `contains` value is matched literally.
341
+ function escapeLikePattern(value: string): string {
342
+ return value.replace(/([\\%_])/g, '\\$1');
343
+ }
344
+
345
+ export function sheetFilterHasValue(op: SheetFilterOp): boolean {
346
+ return !SHEET_FILTER_VALUELESS_OP_SET.has(op);
347
+ }