deepline 0.3.90 → 0.3.92
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/dist/bundling-sources/sdk/src/client.ts +101 -6
- package/dist/bundling-sources/sdk/src/monitor-fleet-contract.ts +2 -556
- package/dist/bundling-sources/sdk/src/monitor-fleets.ts +2 -2
- package/dist/bundling-sources/sdk/src/release.ts +7 -12
- package/dist/bundling-sources/sdk/src/types.ts +6 -1
- package/dist/bundling-sources/shared_libs/integration-contracts/monitor-fleet-contract.ts +549 -0
- package/dist/bundling-sources/shared_libs/monitors/fleets/errors.ts +96 -0
- package/dist/bundling-sources/shared_libs/monitors/fleets/limits.ts +8 -0
- package/dist/bundling-sources/shared_libs/monitors/fleets/plan.ts +320 -0
- package/dist/bundling-sources/shared_libs/monitors/fleets/verdict.ts +106 -0
- package/dist/bundling-sources/shared_libs/monitors/http-error-response.ts +5 -1
- package/dist/bundling-sources/shared_libs/monitors/monitor-fleet-limits.ts +2 -12
- package/dist/bundling-sources/shared_libs/monitors/package.json +107 -5
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-batch-operation.ts +12 -7
- package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +48 -0
- package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/db-session-lease-store.ts +578 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-execution-relay.ts +521 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/roll-receipt-gateway.ts +1378 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/commands/runtime-topology.ts +1024 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-gateway-route-probe.ts +193 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machine-lifecycle.ts +104 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machines.ts +1844 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-registry.ts +21 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/process-tree.ts +40 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-gateway-drain.ts +262 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-surface.ts +98 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-topology.ts +303 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-publication-guardian.ts +70 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/runtime-tuple-recovery.ts +519 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-fly-rollback-alignment.ts +280 -0
- package/dist/bundling-sources/shared_libs/play-runtime/deployment/vercel-production-gate.ts +159 -0
- package/dist/bundling-sources/shared_libs/play-runtime/integration-operation-reader-fence/memory-store.ts +114 -0
- package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +44 -5
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/child-play-resolver.ts +26 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +3662 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/progress-sidecar.ts +47 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-deadline.ts +84 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-gateway-transport.ts +91 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-heartbeat.ts +424 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-progress-push.ts +321 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-sheet-persistence-retry.ts +110 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-terminal-push.ts +625 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-api.ts +46 -3
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-gateway.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +50 -0
- package/dist/bundling-sources/shared_libs/plays/bundling/repository-adapter.ts +24 -0
- package/dist/bundling-sources/shared_libs/plays/child-run-projection.ts +174 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/api-client.ts +267 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/control-plane-types.ts +73 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/manifest.ts +162 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/project-serializer.ts +87 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/source-file-path.ts +26 -0
- package/dist/bundling-sources/shared_libs/plays/deploy/target-paths.ts +36 -0
- package/dist/bundling-sources/shared_libs/plays/launch/input-contract.ts +400 -0
- package/dist/bundling-sources/shared_libs/plays/launch/prepare.ts +96 -0
- package/dist/bundling-sources/shared_libs/plays/local-file-discovery.ts +385 -0
- package/dist/bundling-sources/shared_libs/plays/play-grep-search.ts +79 -6
- package/dist/bundling-sources/shared_libs/plays/play-reference.ts +117 -0
- package/dist/bundling-sources/shared_libs/plays/run-input-summary.ts +260 -0
- package/dist/bundling-sources/shared_libs/plays/run-summary-types.ts +49 -0
- package/dist/bundling-sources/shared_libs/plays/secrets/errors.ts +19 -0
- package/dist/bundling-sources/shared_libs/plays/sheet-filter.ts +347 -0
- package/dist/bundling-sources/shared_libs/plays/standard-webhooks.ts +117 -0
- package/dist/bundling-sources/shared_libs/plays/status-result.ts +388 -0
- package/dist/bundling-sources/shared_libs/plays/transform-factor-structure.ts +377 -0
- package/dist/bundling-sources/shared_libs/plays/webhook-payload.ts +113 -0
- package/dist/cli/index.js +369 -162
- package/dist/cli/index.mjs +369 -162
- package/dist/{compiler-manifest-BaJfE-5H.d.mts → compiler-manifest-x14KL83E.d.mts} +1 -1
- package/dist/{compiler-manifest-BaJfE-5H.d.ts → compiler-manifest-x14KL83E.d.ts} +1 -1
- package/dist/index.d.mts +45 -8
- package/dist/index.d.ts +45 -8
- package/dist/index.js +61 -14
- package/dist/index.mjs +61 -14
- package/dist/install-integrity.json +54 -2
- package/dist/monitor-fleet-contract.d.mts +3 -10
- package/dist/monitor-fleet-contract.d.ts +3 -10
- package/dist/monitor-fleet-contract.js +2 -0
- package/dist/monitor-fleet-contract.mjs +1 -1
- package/dist/plays/bundle-play-file.d.mts +2 -2
- package/dist/plays/bundle-play-file.d.ts +2 -2
- package/dist/release.d.mts +3 -3
- package/dist/release.d.ts +3 -3
- package/dist/release.js +7 -12
- package/dist/release.mjs +7 -12
- 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
|
+
}
|