deepline 0.1.0 → 0.1.2
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/cli/index.js +212 -54
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +198 -40
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/repo/apps/play-runner-workers/src/coordinator-entry.ts +3256 -0
- package/dist/repo/apps/play-runner-workers/src/dedup-do.ts +710 -0
- package/dist/repo/apps/play-runner-workers/src/entry.ts +5070 -0
- package/dist/repo/apps/play-runner-workers/src/runtime/README.md +21 -0
- package/dist/repo/apps/play-runner-workers/src/runtime/batching.ts +177 -0
- package/dist/repo/apps/play-runner-workers/src/runtime/execution-plan.ts +52 -0
- package/dist/repo/apps/play-runner-workers/src/runtime/tool-batch.ts +100 -0
- package/dist/repo/apps/play-runner-workers/src/runtime/tool-result.ts +184 -0
- package/dist/repo/sdk/src/cli/commands/auth.ts +482 -0
- package/dist/repo/sdk/src/cli/commands/billing.ts +188 -0
- package/dist/repo/sdk/src/cli/commands/csv.ts +123 -0
- package/dist/repo/sdk/src/cli/commands/db.ts +119 -0
- package/dist/repo/sdk/src/cli/commands/feedback.ts +40 -0
- package/dist/repo/sdk/src/cli/commands/org.ts +117 -0
- package/dist/repo/sdk/src/cli/commands/play.ts +3200 -0
- package/dist/repo/sdk/src/cli/commands/tools.ts +687 -0
- package/dist/repo/sdk/src/cli/dataset-stats.ts +341 -0
- package/dist/repo/sdk/src/cli/index.ts +138 -0
- package/dist/repo/sdk/src/cli/progress.ts +135 -0
- package/dist/repo/sdk/src/cli/trace.ts +61 -0
- package/dist/repo/sdk/src/cli/utils.ts +145 -0
- package/dist/repo/sdk/src/client.ts +1188 -0
- package/dist/repo/sdk/src/compat.ts +77 -0
- package/dist/repo/sdk/src/config.ts +285 -0
- package/dist/repo/sdk/src/errors.ts +125 -0
- package/dist/repo/sdk/src/http.ts +391 -0
- package/dist/repo/sdk/src/index.ts +139 -0
- package/dist/repo/sdk/src/play.ts +1330 -0
- package/dist/repo/sdk/src/plays/bundle-play-file.ts +133 -0
- package/dist/repo/sdk/src/plays/harness-stub.ts +210 -0
- package/dist/repo/sdk/src/plays/local-file-discovery.ts +326 -0
- package/dist/repo/sdk/src/tool-output.ts +489 -0
- package/dist/repo/sdk/src/types.ts +669 -0
- package/dist/repo/sdk/src/version.ts +2 -0
- package/dist/repo/sdk/src/worker-play-entry.ts +286 -0
- package/dist/repo/shared_libs/observability/node-tracing.ts +129 -0
- package/dist/repo/shared_libs/observability/tracing.ts +98 -0
- package/dist/repo/shared_libs/play-runtime/backend.ts +139 -0
- package/dist/repo/shared_libs/play-runtime/batch-runtime.ts +182 -0
- package/dist/repo/shared_libs/play-runtime/batching-types.ts +91 -0
- package/dist/repo/shared_libs/play-runtime/context.ts +3999 -0
- package/dist/repo/shared_libs/play-runtime/coordinator-headers.ts +78 -0
- package/dist/repo/shared_libs/play-runtime/ctx-contract.ts +250 -0
- package/dist/repo/shared_libs/play-runtime/ctx-types.ts +713 -0
- package/dist/repo/shared_libs/play-runtime/dataset-id.ts +10 -0
- package/dist/repo/shared_libs/play-runtime/db-session-crypto.ts +304 -0
- package/dist/repo/shared_libs/play-runtime/db-session.ts +462 -0
- package/dist/repo/shared_libs/play-runtime/dedup-backend.ts +0 -0
- package/dist/repo/shared_libs/play-runtime/default-batch-strategies.ts +124 -0
- package/dist/repo/shared_libs/play-runtime/execution-plan.ts +262 -0
- package/dist/repo/shared_libs/play-runtime/live-events.ts +214 -0
- package/dist/repo/shared_libs/play-runtime/live-state-contract.ts +50 -0
- package/dist/repo/shared_libs/play-runtime/map-execution-frame.ts +114 -0
- package/dist/repo/shared_libs/play-runtime/map-row-identity.ts +158 -0
- package/dist/repo/shared_libs/play-runtime/profiles.ts +90 -0
- package/dist/repo/shared_libs/play-runtime/progress-emitter.ts +172 -0
- package/dist/repo/shared_libs/play-runtime/protocol.ts +121 -0
- package/dist/repo/shared_libs/play-runtime/public-play-contract.ts +42 -0
- package/dist/repo/shared_libs/play-runtime/result-normalization.ts +33 -0
- package/dist/repo/shared_libs/play-runtime/runtime-actions.ts +208 -0
- package/dist/repo/shared_libs/play-runtime/runtime-api.ts +1873 -0
- package/dist/repo/shared_libs/play-runtime/runtime-constraints.ts +2 -0
- package/dist/repo/shared_libs/play-runtime/runtime-pg-driver-neon-serverless.ts +201 -0
- package/dist/repo/shared_libs/play-runtime/runtime-pg-driver-pg.ts +48 -0
- package/dist/repo/shared_libs/play-runtime/runtime-pg-driver.ts +84 -0
- package/dist/repo/shared_libs/play-runtime/scheduler-backend.ts +174 -0
- package/dist/repo/shared_libs/play-runtime/static-pipeline-types.ts +147 -0
- package/dist/repo/shared_libs/play-runtime/suspension.ts +68 -0
- package/dist/repo/shared_libs/play-runtime/tool-batch-executor.ts +146 -0
- package/dist/repo/shared_libs/play-runtime/tool-result.ts +387 -0
- package/dist/repo/shared_libs/play-runtime/tracing.ts +31 -0
- package/dist/repo/shared_libs/play-runtime/waterfall-replay.ts +75 -0
- package/dist/repo/shared_libs/play-runtime/worker-api-types.ts +140 -0
- package/dist/repo/shared_libs/plays/artifact-transport.ts +14 -0
- package/dist/repo/shared_libs/plays/artifact-types.ts +49 -0
- package/dist/repo/shared_libs/plays/bundling/index.ts +1346 -0
- package/dist/repo/shared_libs/plays/compiler-manifest.ts +186 -0
- package/dist/repo/shared_libs/plays/contracts.ts +51 -0
- package/dist/repo/shared_libs/plays/dataset.ts +308 -0
- package/dist/repo/shared_libs/plays/definition.ts +264 -0
- package/dist/repo/shared_libs/plays/file-refs.ts +11 -0
- package/dist/repo/shared_libs/plays/rate-limit-scheduler.ts +206 -0
- package/dist/repo/shared_libs/plays/resolve-static-pipeline.ts +164 -0
- package/dist/repo/shared_libs/plays/row-identity.ts +302 -0
- package/dist/repo/shared_libs/plays/runtime-validation.ts +415 -0
- package/dist/repo/shared_libs/plays/static-pipeline.ts +560 -0
- package/dist/repo/shared_libs/temporal/constants.ts +39 -0
- package/dist/repo/shared_libs/temporal/preview-config.ts +153 -0
- package/package.json +4 -4
|
@@ -0,0 +1,1873 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
createRuntimePool,
|
|
4
|
+
type RuntimePool,
|
|
5
|
+
type RuntimePoolClient,
|
|
6
|
+
} from './runtime-pg-driver';
|
|
7
|
+
import { createDeferredPlayDataset, type PlayDataset } from '../plays/dataset';
|
|
8
|
+
import type {
|
|
9
|
+
PlayStaticPipeline,
|
|
10
|
+
PlaySheetContract,
|
|
11
|
+
} from './static-pipeline-types';
|
|
12
|
+
import type {
|
|
13
|
+
CreateDbSessionResponse,
|
|
14
|
+
DbLogicalTable,
|
|
15
|
+
DbSessionLimits,
|
|
16
|
+
DbSessionOperation,
|
|
17
|
+
PreloadedRuntimeDbSession,
|
|
18
|
+
RowsWriteResponse,
|
|
19
|
+
} from './db-session';
|
|
20
|
+
import {
|
|
21
|
+
derivePlayRowIdentity,
|
|
22
|
+
normalizePlayNameForSheet,
|
|
23
|
+
normalizeTableNamespace,
|
|
24
|
+
} from '../plays/row-identity';
|
|
25
|
+
import {
|
|
26
|
+
createDbSessionResponseSchema,
|
|
27
|
+
rowsWriteResponseSchema,
|
|
28
|
+
} from './db-session';
|
|
29
|
+
import {
|
|
30
|
+
dbSessionPostgresUrlAad,
|
|
31
|
+
decryptDbSessionPostgresUrl,
|
|
32
|
+
decryptDbSessionPostgresUrlWithPrivateKey,
|
|
33
|
+
generateDbSessionPostgresUrlDecryptionKey,
|
|
34
|
+
type PostgresUrlDecryptionKey,
|
|
35
|
+
} from './db-session-crypto';
|
|
36
|
+
import { createRuntimeDatasetId } from './dataset-id';
|
|
37
|
+
|
|
38
|
+
type RuntimeApiContext = {
|
|
39
|
+
baseUrl?: string | null;
|
|
40
|
+
executorToken?: string | null;
|
|
41
|
+
orgId?: string | null;
|
|
42
|
+
playName?: string | null;
|
|
43
|
+
userEmail?: string | null;
|
|
44
|
+
preloadedDbSessions?: PreloadedRuntimeDbSession[] | null;
|
|
45
|
+
vercelProtectionBypassToken?: string | null;
|
|
46
|
+
disablePostgresPoolCache?: boolean | null;
|
|
47
|
+
postgresSessionUnwrapKey?: string | null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type DbSessionCacheEntry = {
|
|
51
|
+
session: CreateDbSessionResponse;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const dbSessionCache = new Map<string, DbSessionCacheEntry>();
|
|
55
|
+
const postgresPools = new Map<string, RuntimePool>();
|
|
56
|
+
const DIRECT_POSTGRES_BATCH_SIZE = 5_000;
|
|
57
|
+
const APPEND_KEY_SUFFIX_LENGTH = 12;
|
|
58
|
+
const RUNTIME_DB_SESSION_TTL_SECONDS = 10 * 60;
|
|
59
|
+
const RUNTIME_DB_SESSION_RENEWAL_WINDOW_MS = 60_000;
|
|
60
|
+
const RUNTIME_API_MAX_ATTEMPTS = 5;
|
|
61
|
+
const RUNTIME_API_DEFAULT_RETRY_AFTER_MS = 2_000;
|
|
62
|
+
const RUNTIME_API_REQUEST_TIMEOUT_MS = 30_000;
|
|
63
|
+
const RUNTIME_POSTGRES_PREWARM_MAX_ATTEMPTS = 4;
|
|
64
|
+
const RUNTIME_POSTGRES_PREWARM_RETRY_DELAYS_MS = [250, 750, 1_500] as const;
|
|
65
|
+
const RUNTIME_POSTGRES_CONNECT_MAX_ATTEMPTS = 4;
|
|
66
|
+
const RUNTIME_POSTGRES_CONNECT_RETRY_DELAYS_MS = [250, 750, 1_500] as const;
|
|
67
|
+
|
|
68
|
+
export type ResolvedRuntimePlay = {
|
|
69
|
+
playId: string;
|
|
70
|
+
sourceCode?: string | null;
|
|
71
|
+
artifact?: unknown;
|
|
72
|
+
codeFormat?: 'function' | 'cjs_module' | 'esm_module';
|
|
73
|
+
contractSnapshot?: Record<string, unknown> | null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type PrepareRuntimeSheetResult = {
|
|
77
|
+
inserted: number;
|
|
78
|
+
skipped: number;
|
|
79
|
+
pendingRows: Record<string, unknown>[];
|
|
80
|
+
completedRows: Record<string, unknown>[];
|
|
81
|
+
tableNamespace: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type RuntimeApiActionRequest =
|
|
85
|
+
| {
|
|
86
|
+
action: 'resolve_play';
|
|
87
|
+
playRef: string;
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
action: 'ensure_sheet';
|
|
91
|
+
playName: string;
|
|
92
|
+
tableNamespace: string;
|
|
93
|
+
sheetContract?: PlaySheetContract | null;
|
|
94
|
+
userEmail?: string | null;
|
|
95
|
+
}
|
|
96
|
+
| {
|
|
97
|
+
action: 'start_sheet_dataset';
|
|
98
|
+
playName: string;
|
|
99
|
+
tableNamespace: string;
|
|
100
|
+
sheetContract: PlaySheetContract;
|
|
101
|
+
rows: Record<string, unknown>[];
|
|
102
|
+
runId: string;
|
|
103
|
+
userEmail?: string | null;
|
|
104
|
+
}
|
|
105
|
+
| {
|
|
106
|
+
action: 'create_db_session';
|
|
107
|
+
playName: string;
|
|
108
|
+
target: {
|
|
109
|
+
tableNamespace: string;
|
|
110
|
+
logicalTable: 'sheet_rows' | 'dataset_rows' | 'play_output';
|
|
111
|
+
};
|
|
112
|
+
operations: DbSessionOperation[];
|
|
113
|
+
limits?: {
|
|
114
|
+
maxRows?: number;
|
|
115
|
+
maxBytes?: number;
|
|
116
|
+
maxRequests?: number;
|
|
117
|
+
};
|
|
118
|
+
sheetContract?: PlaySheetContract | null;
|
|
119
|
+
ttlSeconds?: number;
|
|
120
|
+
userEmail?: string | null;
|
|
121
|
+
postgresUrlEncryption?: {
|
|
122
|
+
alg: 'RSA-OAEP-256+A256GCM';
|
|
123
|
+
publicKeyJwk: JsonWebKey;
|
|
124
|
+
} | null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
type RuntimeApiRowRecord = {
|
|
128
|
+
key: string;
|
|
129
|
+
data: Record<string, unknown>;
|
|
130
|
+
cellMetaPatch?: Record<string, unknown>;
|
|
131
|
+
inputIndex?: number | null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
type RuntimePostgresSession = CreateDbSessionResponse & {
|
|
135
|
+
postgresUrl: string;
|
|
136
|
+
postgres: NonNullable<CreateDbSessionResponse['postgres']>;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function resolveRuntimeApiUrl(context: RuntimeApiContext): string {
|
|
140
|
+
const baseUrl = context.baseUrl?.trim();
|
|
141
|
+
if (!baseUrl) {
|
|
142
|
+
throw new Error('Runner runtime API requires a baseUrl.');
|
|
143
|
+
}
|
|
144
|
+
return `${baseUrl.replace(/\/$/, '')}/api/v2/plays/internal/runtime`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolveRuntimeApiHeaders(
|
|
148
|
+
context: RuntimeApiContext,
|
|
149
|
+
): Record<string, string> {
|
|
150
|
+
const token = context.executorToken?.trim();
|
|
151
|
+
if (!token) {
|
|
152
|
+
throw new Error('Runner runtime API requires an executorToken.');
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
'content-type': 'application/json',
|
|
156
|
+
authorization: `Bearer ${token}`,
|
|
157
|
+
...(context.vercelProtectionBypassToken
|
|
158
|
+
? { 'x-vercel-protection-bypass': context.vercelProtectionBypassToken }
|
|
159
|
+
: {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizeRuntimeUserEmail(value: string | null | undefined): string | null {
|
|
164
|
+
const email = value?.trim();
|
|
165
|
+
return email ? email : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function postRuntimeApi<TResponse>(
|
|
169
|
+
context: RuntimeApiContext,
|
|
170
|
+
body: RuntimeApiActionRequest,
|
|
171
|
+
): Promise<TResponse> {
|
|
172
|
+
const url = resolveRuntimeApiUrl(context);
|
|
173
|
+
for (let attempt = 1; attempt <= RUNTIME_API_MAX_ATTEMPTS; attempt += 1) {
|
|
174
|
+
let response: Response;
|
|
175
|
+
const abortController = new AbortController();
|
|
176
|
+
const timeout = setTimeout(
|
|
177
|
+
() => abortController.abort(),
|
|
178
|
+
RUNTIME_API_REQUEST_TIMEOUT_MS,
|
|
179
|
+
);
|
|
180
|
+
try {
|
|
181
|
+
response = await fetch(url, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: resolveRuntimeApiHeaders(context),
|
|
184
|
+
body: JSON.stringify(body),
|
|
185
|
+
signal: abortController.signal,
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (attempt < RUNTIME_API_MAX_ATTEMPTS) {
|
|
189
|
+
await new Promise((resolve) =>
|
|
190
|
+
setTimeout(resolve, RUNTIME_API_DEFAULT_RETRY_AFTER_MS),
|
|
191
|
+
);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Runtime API request to ${url} failed before receiving a response: ${message}`,
|
|
197
|
+
);
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimeout(timeout);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const parsed = (await response.json().catch(() => null)) as Record<
|
|
203
|
+
string,
|
|
204
|
+
unknown
|
|
205
|
+
> | null;
|
|
206
|
+
if (response.ok) {
|
|
207
|
+
return parsed as TResponse;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const retryAfterMs =
|
|
211
|
+
typeof parsed?.retry_after_ms === 'number' &&
|
|
212
|
+
Number.isFinite(parsed.retry_after_ms)
|
|
213
|
+
? parsed.retry_after_ms
|
|
214
|
+
: RUNTIME_API_DEFAULT_RETRY_AFTER_MS;
|
|
215
|
+
const shouldRetryRuntimeResponse =
|
|
216
|
+
(response.status === 503 &&
|
|
217
|
+
parsed?.code === 'ingestion_plane_not_ready') ||
|
|
218
|
+
(body.action === 'create_db_session' &&
|
|
219
|
+
(response.status === 502 ||
|
|
220
|
+
response.status === 503 ||
|
|
221
|
+
response.status === 504));
|
|
222
|
+
if (shouldRetryRuntimeResponse && attempt < RUNTIME_API_MAX_ATTEMPTS) {
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfterMs));
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const errorMessage =
|
|
228
|
+
typeof parsed?.error === 'string'
|
|
229
|
+
? parsed.error
|
|
230
|
+
: `Runtime API request failed with status ${response.status}.`;
|
|
231
|
+
const details =
|
|
232
|
+
typeof parsed?.details === 'string'
|
|
233
|
+
? parsed.details
|
|
234
|
+
: typeof parsed?.debug_error === 'string'
|
|
235
|
+
? parsed.debug_error
|
|
236
|
+
: null;
|
|
237
|
+
throw new Error(
|
|
238
|
+
details
|
|
239
|
+
? `${errorMessage} (status ${response.status}): ${details}`
|
|
240
|
+
: `${errorMessage} (status ${response.status}).`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
throw new Error('Runtime API request failed after retries.');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function getDbSessionCacheKey(input: {
|
|
248
|
+
baseUrl?: string | null;
|
|
249
|
+
executorToken?: string | null;
|
|
250
|
+
playName: string;
|
|
251
|
+
tableNamespace: string;
|
|
252
|
+
logicalTable: DbLogicalTable;
|
|
253
|
+
operations: DbSessionOperation[];
|
|
254
|
+
limits?: DbSessionLimits;
|
|
255
|
+
sheetContract?: PlaySheetContract | null;
|
|
256
|
+
userEmail?: string | null;
|
|
257
|
+
}): string {
|
|
258
|
+
// Worker processes are long-lived. Include a hash of the executor token plus
|
|
259
|
+
// the full requested access shape so a pooled runtime cannot reuse a scoped
|
|
260
|
+
// Postgres URL across orgs, runs, logical tables, or privilege sets.
|
|
261
|
+
const tokenHash = createHash('sha256')
|
|
262
|
+
.update(input.executorToken?.trim() ?? '')
|
|
263
|
+
.digest('hex')
|
|
264
|
+
.slice(0, 24);
|
|
265
|
+
return [
|
|
266
|
+
input.baseUrl?.trim() ?? '',
|
|
267
|
+
tokenHash,
|
|
268
|
+
input.playName,
|
|
269
|
+
input.tableNamespace,
|
|
270
|
+
input.logicalTable,
|
|
271
|
+
[...input.operations].sort().join(','),
|
|
272
|
+
JSON.stringify(input.limits ?? {}),
|
|
273
|
+
JSON.stringify(input.sheetContract ?? null),
|
|
274
|
+
input.userEmail?.trim() ?? '',
|
|
275
|
+
].join('::');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function sameOperations(
|
|
279
|
+
left: readonly DbSessionOperation[],
|
|
280
|
+
right: readonly DbSessionOperation[],
|
|
281
|
+
): boolean {
|
|
282
|
+
return [...left].sort().join(',') === [...right].sort().join(',');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function limitsSatisfyRequest(
|
|
286
|
+
candidate: DbSessionLimits | undefined,
|
|
287
|
+
requested: DbSessionLimits | undefined,
|
|
288
|
+
): boolean {
|
|
289
|
+
const candidateLimits = candidate ?? {};
|
|
290
|
+
const requestedLimits = requested ?? {};
|
|
291
|
+
for (const key of ['maxRows', 'maxBytes', 'maxRequests'] as const) {
|
|
292
|
+
const requestedValue = requestedLimits[key];
|
|
293
|
+
if (requestedValue === undefined) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const candidateValue = candidateLimits[key];
|
|
297
|
+
// A preloaded session with no advisory limit means the app intentionally
|
|
298
|
+
// minted a table-scoped grant for the whole run. The actual database
|
|
299
|
+
// boundary is still the temporary role's table/operation privileges.
|
|
300
|
+
if (candidateValue === undefined) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (candidateValue < requestedValue) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function sessionHasRenewalWindow(session: CreateDbSessionResponse): boolean {
|
|
311
|
+
const expiresAtMs = Date.parse(session.expiresAt);
|
|
312
|
+
return (
|
|
313
|
+
Number.isFinite(expiresAtMs) &&
|
|
314
|
+
expiresAtMs - Date.now() > RUNTIME_DB_SESSION_RENEWAL_WINDOW_MS
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function preloadedSessionMatchesRequest(
|
|
319
|
+
context: RuntimeApiContext & { playName: string },
|
|
320
|
+
input: {
|
|
321
|
+
tableNamespace: string;
|
|
322
|
+
logicalTable: DbLogicalTable;
|
|
323
|
+
operations: DbSessionOperation[];
|
|
324
|
+
limits?: DbSessionLimits;
|
|
325
|
+
},
|
|
326
|
+
preloaded: PreloadedRuntimeDbSession,
|
|
327
|
+
): boolean {
|
|
328
|
+
const session = preloaded.session;
|
|
329
|
+
const requestedTableNamespace = normalizeTableNamespace(input.tableNamespace);
|
|
330
|
+
return (
|
|
331
|
+
sessionHasRenewalWindow(session) &&
|
|
332
|
+
(!context.orgId || session.target.orgId === context.orgId) &&
|
|
333
|
+
session.playName === context.playName &&
|
|
334
|
+
normalizeTableNamespace(preloaded.tableNamespace) ===
|
|
335
|
+
requestedTableNamespace &&
|
|
336
|
+
normalizeTableNamespace(session.target.tableNamespace) ===
|
|
337
|
+
requestedTableNamespace &&
|
|
338
|
+
preloaded.logicalTable === input.logicalTable &&
|
|
339
|
+
session.target.logicalTable === input.logicalTable &&
|
|
340
|
+
sameOperations(preloaded.operations, input.operations) &&
|
|
341
|
+
sameOperations(session.operations, input.operations) &&
|
|
342
|
+
limitsSatisfyRequest(preloaded.limits, input.limits) &&
|
|
343
|
+
limitsSatisfyRequest(session.limits, input.limits)
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function findPreloadedRuntimeDbSession(
|
|
348
|
+
context: RuntimeApiContext & { playName: string },
|
|
349
|
+
input: {
|
|
350
|
+
tableNamespace: string;
|
|
351
|
+
logicalTable: DbLogicalTable;
|
|
352
|
+
operations: DbSessionOperation[];
|
|
353
|
+
limits?: DbSessionLimits;
|
|
354
|
+
},
|
|
355
|
+
): CreateDbSessionResponse | null {
|
|
356
|
+
for (const preloaded of context.preloadedDbSessions ?? []) {
|
|
357
|
+
const parsed = createDbSessionResponseSchema.safeParse(preloaded.session);
|
|
358
|
+
if (!parsed.success) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const candidate = {
|
|
362
|
+
...preloaded,
|
|
363
|
+
session: parsed.data,
|
|
364
|
+
};
|
|
365
|
+
if (preloadedSessionMatchesRequest(context, input, candidate)) {
|
|
366
|
+
return candidate.session;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function unwrapRuntimeDbSession(
|
|
373
|
+
context: RuntimeApiContext,
|
|
374
|
+
session: CreateDbSessionResponse,
|
|
375
|
+
decryptionKey?: PostgresUrlDecryptionKey | null,
|
|
376
|
+
): Promise<CreateDbSessionResponse> {
|
|
377
|
+
if (session.postgresUrl) {
|
|
378
|
+
return session;
|
|
379
|
+
}
|
|
380
|
+
if (!session.encryptedPostgresUrl) {
|
|
381
|
+
return session;
|
|
382
|
+
}
|
|
383
|
+
if (session.encryptedPostgresUrl.alg === 'RSA-OAEP-256+A256GCM') {
|
|
384
|
+
if (!decryptionKey) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
'Runtime DB session response used public-key encryption, but no private key was retained for unwrap.',
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
const { encryptedPostgresUrl: _encryptedPostgresUrl, ...sessionWithoutUrl } =
|
|
390
|
+
session;
|
|
391
|
+
void _encryptedPostgresUrl;
|
|
392
|
+
return {
|
|
393
|
+
...sessionWithoutUrl,
|
|
394
|
+
postgresUrl: await decryptDbSessionPostgresUrlWithPrivateKey({
|
|
395
|
+
encrypted: session.encryptedPostgresUrl,
|
|
396
|
+
privateKey: decryptionKey.privateKey,
|
|
397
|
+
aad: dbSessionPostgresUrlAad(sessionWithoutUrl),
|
|
398
|
+
}),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const unwrapKey = context.postgresSessionUnwrapKey?.trim();
|
|
402
|
+
if (!unwrapKey) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
'Runtime DB session response is encrypted, but no harness unwrap key was provided.',
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
const { encryptedPostgresUrl: _encryptedPostgresUrl, ...sessionWithoutUrl } =
|
|
408
|
+
session;
|
|
409
|
+
void _encryptedPostgresUrl;
|
|
410
|
+
return {
|
|
411
|
+
...sessionWithoutUrl,
|
|
412
|
+
postgresUrl: await decryptDbSessionPostgresUrl({
|
|
413
|
+
encrypted: session.encryptedPostgresUrl,
|
|
414
|
+
secret: unwrapKey,
|
|
415
|
+
aad: dbSessionPostgresUrlAad(sessionWithoutUrl),
|
|
416
|
+
}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function getRuntimeDbSession(
|
|
421
|
+
context: RuntimeApiContext & { playName: string },
|
|
422
|
+
input: {
|
|
423
|
+
tableNamespace: string;
|
|
424
|
+
logicalTable: DbLogicalTable;
|
|
425
|
+
operations: DbSessionOperation[];
|
|
426
|
+
limits?: DbSessionLimits;
|
|
427
|
+
sheetContract?: PlaySheetContract | null;
|
|
428
|
+
},
|
|
429
|
+
): Promise<CreateDbSessionResponse> {
|
|
430
|
+
const userEmail = normalizeRuntimeUserEmail(context.userEmail);
|
|
431
|
+
const cacheKey = getDbSessionCacheKey({
|
|
432
|
+
baseUrl: context.baseUrl,
|
|
433
|
+
executorToken: context.executorToken,
|
|
434
|
+
playName: context.playName,
|
|
435
|
+
tableNamespace: input.tableNamespace,
|
|
436
|
+
logicalTable: input.logicalTable,
|
|
437
|
+
operations: input.operations,
|
|
438
|
+
limits: input.limits,
|
|
439
|
+
sheetContract: input.sheetContract,
|
|
440
|
+
userEmail,
|
|
441
|
+
});
|
|
442
|
+
const cached = dbSessionCache.get(cacheKey)?.session;
|
|
443
|
+
if (cached) {
|
|
444
|
+
if (sessionHasRenewalWindow(cached)) {
|
|
445
|
+
return cached;
|
|
446
|
+
}
|
|
447
|
+
await deleteRuntimeDbSessionCacheEntry(cacheKey, cached);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const preloaded = findPreloadedRuntimeDbSession(context, input);
|
|
451
|
+
if (preloaded) {
|
|
452
|
+
const unwrappedPreloaded = await unwrapRuntimeDbSession(context, preloaded);
|
|
453
|
+
dbSessionCache.set(cacheKey, { session: unwrappedPreloaded });
|
|
454
|
+
return unwrappedPreloaded;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const decryptionKey = await generateDbSessionPostgresUrlDecryptionKey();
|
|
458
|
+
const response = await unwrapRuntimeDbSession(
|
|
459
|
+
context,
|
|
460
|
+
createDbSessionResponseSchema.parse(
|
|
461
|
+
await postRuntimeApi<CreateDbSessionResponse>(context, {
|
|
462
|
+
action: 'create_db_session',
|
|
463
|
+
playName: context.playName,
|
|
464
|
+
target: {
|
|
465
|
+
tableNamespace: input.tableNamespace,
|
|
466
|
+
logicalTable: input.logicalTable,
|
|
467
|
+
},
|
|
468
|
+
operations: input.operations,
|
|
469
|
+
limits: input.limits,
|
|
470
|
+
sheetContract: input.sheetContract ?? null,
|
|
471
|
+
ttlSeconds: RUNTIME_DB_SESSION_TTL_SECONDS,
|
|
472
|
+
userEmail,
|
|
473
|
+
postgresUrlEncryption: decryptionKey.request,
|
|
474
|
+
}),
|
|
475
|
+
),
|
|
476
|
+
decryptionKey,
|
|
477
|
+
);
|
|
478
|
+
dbSessionCache.set(cacheKey, { session: response });
|
|
479
|
+
return response;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function deleteRuntimeDbSessionCacheEntry(
|
|
483
|
+
cacheKey: string,
|
|
484
|
+
session: CreateDbSessionResponse,
|
|
485
|
+
): Promise<void> {
|
|
486
|
+
dbSessionCache.delete(cacheKey);
|
|
487
|
+
if (session.postgresUrl) {
|
|
488
|
+
const pool = postgresPools.get(session.postgresUrl);
|
|
489
|
+
postgresPools.delete(session.postgresUrl);
|
|
490
|
+
await pool?.end().catch(() => {});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function requireRuntimePostgresSession(
|
|
495
|
+
session: CreateDbSessionResponse,
|
|
496
|
+
): RuntimePostgresSession {
|
|
497
|
+
if (!session.postgresUrl || !session.postgres) {
|
|
498
|
+
throw new Error(
|
|
499
|
+
'Runtime DB session did not include a scoped Postgres URL. Direct Postgres sheet IO is required.',
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
return session as RuntimePostgresSession;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export async function prewarmRuntimePostgresSessions(
|
|
506
|
+
context: RuntimeApiContext,
|
|
507
|
+
): Promise<void> {
|
|
508
|
+
const sessions = context.preloadedDbSessions ?? [];
|
|
509
|
+
if (sessions.length === 0) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
for (const preloaded of sessions) {
|
|
513
|
+
const parsed = createDbSessionResponseSchema.parse(preloaded.session);
|
|
514
|
+
const session = requireRuntimePostgresSession(parsed);
|
|
515
|
+
await prewarmRuntimePostgresSession(session);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function prewarmRuntimePostgresSession(
|
|
520
|
+
session: RuntimePostgresSession,
|
|
521
|
+
): Promise<void> {
|
|
522
|
+
for (
|
|
523
|
+
let attempt = 1;
|
|
524
|
+
attempt <= RUNTIME_POSTGRES_PREWARM_MAX_ATTEMPTS;
|
|
525
|
+
attempt += 1
|
|
526
|
+
) {
|
|
527
|
+
try {
|
|
528
|
+
await withRuntimePostgres(session, async (client) => {
|
|
529
|
+
await client.query('SELECT 1');
|
|
530
|
+
});
|
|
531
|
+
return;
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (
|
|
534
|
+
attempt >= RUNTIME_POSTGRES_PREWARM_MAX_ATTEMPTS ||
|
|
535
|
+
!isTransientRuntimePostgresConnectionError(error)
|
|
536
|
+
) {
|
|
537
|
+
const pool = postgresPools.get(session.postgresUrl);
|
|
538
|
+
postgresPools.delete(session.postgresUrl);
|
|
539
|
+
if (pool) {
|
|
540
|
+
await Promise.resolve(pool.end()).catch(() => {});
|
|
541
|
+
}
|
|
542
|
+
throw error;
|
|
543
|
+
}
|
|
544
|
+
const pool = postgresPools.get(session.postgresUrl);
|
|
545
|
+
postgresPools.delete(session.postgresUrl);
|
|
546
|
+
if (pool) {
|
|
547
|
+
await Promise.resolve(pool.end()).catch(() => {});
|
|
548
|
+
}
|
|
549
|
+
await sleep(
|
|
550
|
+
RUNTIME_POSTGRES_PREWARM_RETRY_DELAYS_MS[attempt - 1] ??
|
|
551
|
+
RUNTIME_POSTGRES_PREWARM_RETRY_DELAYS_MS[
|
|
552
|
+
RUNTIME_POSTGRES_PREWARM_RETRY_DELAYS_MS.length - 1
|
|
553
|
+
],
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function isTransientRuntimePostgresConnectionError(error: unknown): boolean {
|
|
560
|
+
if (!error || typeof error !== 'object') {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
const code = 'code' in error ? String(error.code) : '';
|
|
564
|
+
if (
|
|
565
|
+
code === 'ECONNRESET' ||
|
|
566
|
+
code === 'ETIMEDOUT' ||
|
|
567
|
+
code === 'ECONNREFUSED' ||
|
|
568
|
+
code === '57P01'
|
|
569
|
+
) {
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
const message = 'message' in error ? String(error.message) : '';
|
|
573
|
+
return /connection (terminated|timeout|timed out|closed|reset)/i.test(
|
|
574
|
+
message,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function sleep(ms: number): Promise<void> {
|
|
579
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function getPostgresPool(postgresUrl: string, cachePool = true): RuntimePool {
|
|
583
|
+
if (!cachePool) {
|
|
584
|
+
return createRuntimePool({
|
|
585
|
+
connectionString: postgresUrl,
|
|
586
|
+
maxConnections: 1,
|
|
587
|
+
idleTimeoutMs: 0,
|
|
588
|
+
connectTimeoutMs: 10_000,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
const existing = postgresPools.get(postgresUrl);
|
|
592
|
+
if (existing) {
|
|
593
|
+
return existing;
|
|
594
|
+
}
|
|
595
|
+
const pool = createRuntimePool({
|
|
596
|
+
connectionString: postgresUrl,
|
|
597
|
+
maxConnections: 4,
|
|
598
|
+
idleTimeoutMs: 15_000,
|
|
599
|
+
connectTimeoutMs: 10_000,
|
|
600
|
+
});
|
|
601
|
+
postgresPools.set(postgresUrl, pool);
|
|
602
|
+
return pool;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function canReuseRuntimePostgresPoolsAcrossRequests(): boolean {
|
|
606
|
+
const navigatorLike = globalThis as typeof globalThis & {
|
|
607
|
+
navigator?: { userAgent?: string };
|
|
608
|
+
};
|
|
609
|
+
const userAgent = navigatorLike.navigator?.userAgent ?? '';
|
|
610
|
+
return !/\bCloudflare-Workers\b/i.test(userAgent);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function withRuntimePostgres<T>(
|
|
614
|
+
session: RuntimePostgresSession,
|
|
615
|
+
fn: (client: RuntimePoolClient) => Promise<T>,
|
|
616
|
+
options: { cachePool?: boolean } = {},
|
|
617
|
+
): Promise<T> {
|
|
618
|
+
let client: RuntimePoolClient | null = null;
|
|
619
|
+
let requestLocalPool: RuntimePool | null = null;
|
|
620
|
+
const cachePool =
|
|
621
|
+
(options.cachePool ?? true) && canReuseRuntimePostgresPoolsAcrossRequests();
|
|
622
|
+
for (
|
|
623
|
+
let attempt = 1;
|
|
624
|
+
attempt <= RUNTIME_POSTGRES_CONNECT_MAX_ATTEMPTS;
|
|
625
|
+
attempt += 1
|
|
626
|
+
) {
|
|
627
|
+
try {
|
|
628
|
+
const pool = getPostgresPool(session.postgresUrl, cachePool);
|
|
629
|
+
if (!cachePool) {
|
|
630
|
+
requestLocalPool = pool;
|
|
631
|
+
}
|
|
632
|
+
client = await pool.connect();
|
|
633
|
+
break;
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (cachePool) {
|
|
636
|
+
const pool = postgresPools.get(session.postgresUrl);
|
|
637
|
+
postgresPools.delete(session.postgresUrl);
|
|
638
|
+
if (pool) {
|
|
639
|
+
await Promise.resolve(pool.end()).catch(() => {});
|
|
640
|
+
}
|
|
641
|
+
} else if (requestLocalPool) {
|
|
642
|
+
await Promise.resolve(requestLocalPool.end()).catch(() => {});
|
|
643
|
+
requestLocalPool = null;
|
|
644
|
+
}
|
|
645
|
+
if (
|
|
646
|
+
attempt >= RUNTIME_POSTGRES_CONNECT_MAX_ATTEMPTS ||
|
|
647
|
+
!isTransientRuntimePostgresConnectionError(error)
|
|
648
|
+
) {
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
await sleep(
|
|
652
|
+
RUNTIME_POSTGRES_CONNECT_RETRY_DELAYS_MS[attempt - 1] ??
|
|
653
|
+
RUNTIME_POSTGRES_CONNECT_RETRY_DELAYS_MS[
|
|
654
|
+
RUNTIME_POSTGRES_CONNECT_RETRY_DELAYS_MS.length - 1
|
|
655
|
+
],
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if (!client) {
|
|
660
|
+
throw new Error('Runtime Postgres connection was not acquired.');
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
return await fn(client);
|
|
664
|
+
} finally {
|
|
665
|
+
client.release();
|
|
666
|
+
if (requestLocalPool) {
|
|
667
|
+
await Promise.resolve(requestLocalPool.end()).catch(() => {});
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function quoteIdentifier(value: string): string {
|
|
673
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function quoteLiteral(value: string): string {
|
|
677
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function fqRuntimeTable(
|
|
681
|
+
session: RuntimePostgresSession,
|
|
682
|
+
table: string,
|
|
683
|
+
): string {
|
|
684
|
+
return `${quoteIdentifier(session.postgres.schema)}.${quoteIdentifier(table)}`;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function sheetTable(session: RuntimePostgresSession): string {
|
|
688
|
+
return fqRuntimeTable(session, session.postgres.sheetTable);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function eventTable(session: RuntimePostgresSession): string {
|
|
692
|
+
return fqRuntimeTable(session, session.postgres.eventTable);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function summaryTable(session: RuntimePostgresSession): string {
|
|
696
|
+
return fqRuntimeTable(session, session.postgres.summaryTable);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function columnSummaryTable(session: RuntimePostgresSession): string {
|
|
700
|
+
return fqRuntimeTable(session, session.postgres.columnSummaryTable);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
type PhysicalSheetColumnProjection = {
|
|
704
|
+
sqlName: string;
|
|
705
|
+
fieldName: string;
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
function physicalSheetColumnProjections(
|
|
709
|
+
sheetContract: PlaySheetContract | null | undefined,
|
|
710
|
+
): PhysicalSheetColumnProjection[] {
|
|
711
|
+
if (!sheetContract) {
|
|
712
|
+
return [];
|
|
713
|
+
}
|
|
714
|
+
const seen = new Set<string>();
|
|
715
|
+
const columns: PhysicalSheetColumnProjection[] = [];
|
|
716
|
+
for (const column of sheetContract.columns) {
|
|
717
|
+
const name = column.sqlName.trim();
|
|
718
|
+
if (!name || name.startsWith('_') || seen.has(name)) {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
seen.add(name);
|
|
722
|
+
columns.push({
|
|
723
|
+
sqlName: name,
|
|
724
|
+
fieldName:
|
|
725
|
+
column.source === 'input' || column.source === 'mapField'
|
|
726
|
+
? column.field?.trim() || column.id.trim() || name
|
|
727
|
+
: name,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
return columns;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function ensureRuntimePhysicalSheetColumns(
|
|
734
|
+
client: RuntimePoolClient,
|
|
735
|
+
session: RuntimePostgresSession,
|
|
736
|
+
sheetContract: PlaySheetContract | null | undefined,
|
|
737
|
+
): Promise<void> {
|
|
738
|
+
const physicalColumns = physicalSheetColumnProjections(sheetContract).map(
|
|
739
|
+
(column) => column.sqlName,
|
|
740
|
+
);
|
|
741
|
+
if (physicalColumns.length === 0) {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
await client.query(`
|
|
745
|
+
ALTER TABLE ${sheetTable(session)}
|
|
746
|
+
${physicalColumns
|
|
747
|
+
.map(
|
|
748
|
+
(column) => `ADD COLUMN IF NOT EXISTS ${quoteIdentifier(column)} jsonb`,
|
|
749
|
+
)
|
|
750
|
+
.join(',\n ')}
|
|
751
|
+
`);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function physicalSheetColumnNames(
|
|
755
|
+
sheetContract: PlaySheetContract | null | undefined,
|
|
756
|
+
): string[] {
|
|
757
|
+
return physicalSheetColumnProjections(sheetContract).map(
|
|
758
|
+
(column) => column.sqlName,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function isSystemSheetColumn(columnName: string): boolean {
|
|
763
|
+
switch (columnName) {
|
|
764
|
+
case '_key':
|
|
765
|
+
case '_status':
|
|
766
|
+
case '_run_id':
|
|
767
|
+
case '_error':
|
|
768
|
+
case '_stage':
|
|
769
|
+
case '_provider':
|
|
770
|
+
case '_input_index':
|
|
771
|
+
case '_created_at':
|
|
772
|
+
case '_updated_at':
|
|
773
|
+
case '_cell_meta':
|
|
774
|
+
case 'seq':
|
|
775
|
+
return true;
|
|
776
|
+
default:
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function mapRuntimePostgresRow(
|
|
782
|
+
raw: Record<string, unknown>,
|
|
783
|
+
): RuntimeApiRowRecord {
|
|
784
|
+
return {
|
|
785
|
+
key: String(raw._key ?? ''),
|
|
786
|
+
data: Object.fromEntries(
|
|
787
|
+
Object.entries(raw).filter(([key]) => !isSystemSheetColumn(key)),
|
|
788
|
+
),
|
|
789
|
+
inputIndex: raw._input_index != null ? Number(raw._input_index) : undefined,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function mergeRuntimeCompletedRow(input: {
|
|
794
|
+
inputRow: Record<string, unknown>;
|
|
795
|
+
completedData: Record<string, unknown>;
|
|
796
|
+
sheetContract: PlaySheetContract;
|
|
797
|
+
}): Record<string, unknown> {
|
|
798
|
+
const syntheticNullInputColumns = new Set(
|
|
799
|
+
input.sheetContract.columns.flatMap((column) => {
|
|
800
|
+
const field = column.field;
|
|
801
|
+
if (
|
|
802
|
+
column.source !== 'input' ||
|
|
803
|
+
typeof field !== 'string' ||
|
|
804
|
+
field in input.inputRow ||
|
|
805
|
+
input.completedData[field] != null
|
|
806
|
+
) {
|
|
807
|
+
return [];
|
|
808
|
+
}
|
|
809
|
+
return [field];
|
|
810
|
+
}),
|
|
811
|
+
);
|
|
812
|
+
const cleanedCompletedData = Object.fromEntries(
|
|
813
|
+
Object.entries(input.completedData).filter(
|
|
814
|
+
([key]) => !syntheticNullInputColumns.has(key),
|
|
815
|
+
),
|
|
816
|
+
);
|
|
817
|
+
return {
|
|
818
|
+
...input.inputRow,
|
|
819
|
+
...cleanedCompletedData,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function buildAppendedRowKey(input: {
|
|
824
|
+
row: Record<string, unknown>;
|
|
825
|
+
tableNamespace: string;
|
|
826
|
+
idempotencyKey: string;
|
|
827
|
+
ordinal: number;
|
|
828
|
+
}): string {
|
|
829
|
+
const baseKey = derivePlayRowIdentity(input.row, input.tableNamespace);
|
|
830
|
+
const suffix = createHash('sha1')
|
|
831
|
+
.update(`${input.idempotencyKey}:${input.ordinal}`)
|
|
832
|
+
.digest('hex')
|
|
833
|
+
.slice(0, APPEND_KEY_SUFFIX_LENGTH);
|
|
834
|
+
return `${baseKey}:append:${suffix}`;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function chunkValues<T>(values: readonly T[], chunkSize: number): T[][] {
|
|
838
|
+
const chunks: T[][] = [];
|
|
839
|
+
for (let index = 0; index < values.length; index += chunkSize) {
|
|
840
|
+
chunks.push(values.slice(index, index + chunkSize));
|
|
841
|
+
}
|
|
842
|
+
return chunks;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function readRuntimeRows(
|
|
846
|
+
session: RuntimePostgresSession,
|
|
847
|
+
input: { limit: number; offset: number },
|
|
848
|
+
): Promise<RuntimeApiRowRecord[]> {
|
|
849
|
+
return await withRuntimePostgres(session, async (client) => {
|
|
850
|
+
const { rows } = await client.query(
|
|
851
|
+
`SELECT *
|
|
852
|
+
FROM ${sheetTable(session)}
|
|
853
|
+
ORDER BY _input_index ASC NULLS LAST, _created_at ASC, _key ASC
|
|
854
|
+
LIMIT $1 OFFSET $2`,
|
|
855
|
+
[input.limit, input.offset],
|
|
856
|
+
);
|
|
857
|
+
return rows.map(mapRuntimePostgresRow);
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
async function readRuntimeRowsPage(
|
|
862
|
+
session: RuntimePostgresSession,
|
|
863
|
+
input: {
|
|
864
|
+
afterInputIndex: number;
|
|
865
|
+
limit: number;
|
|
866
|
+
statuses?: string[];
|
|
867
|
+
},
|
|
868
|
+
): Promise<RuntimeApiRowRecord[]> {
|
|
869
|
+
return await withRuntimePostgres(session, async (client) => {
|
|
870
|
+
const rawStatuses = (input.statuses ?? []).map((status) =>
|
|
871
|
+
status === 'completed'
|
|
872
|
+
? 'enriched'
|
|
873
|
+
: status === 'queued'
|
|
874
|
+
? 'pending'
|
|
875
|
+
: status,
|
|
876
|
+
);
|
|
877
|
+
const params: unknown[] = [input.afterInputIndex, input.limit];
|
|
878
|
+
const conditions = ['_input_index > $1'];
|
|
879
|
+
if (rawStatuses.length > 0) {
|
|
880
|
+
params.push(rawStatuses);
|
|
881
|
+
conditions.push(`_status = ANY($${params.length}::text[])`);
|
|
882
|
+
}
|
|
883
|
+
const { rows } = await client.query(
|
|
884
|
+
`SELECT *
|
|
885
|
+
FROM ${sheetTable(session)}
|
|
886
|
+
WHERE ${conditions.join(' AND ')}
|
|
887
|
+
ORDER BY _input_index ASC NULLS LAST, _created_at ASC, _key ASC
|
|
888
|
+
LIMIT $2`,
|
|
889
|
+
params,
|
|
890
|
+
);
|
|
891
|
+
return rows.map(mapRuntimePostgresRow);
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
async function readRuntimeRowsByKeys(
|
|
896
|
+
client: RuntimePoolClient,
|
|
897
|
+
session: RuntimePostgresSession,
|
|
898
|
+
keys: string[],
|
|
899
|
+
): Promise<RuntimeApiRowRecord[]> {
|
|
900
|
+
if (keys.length === 0) {
|
|
901
|
+
return [];
|
|
902
|
+
}
|
|
903
|
+
const { rows } = await client.query<Record<string, unknown>>(
|
|
904
|
+
`SELECT *
|
|
905
|
+
FROM ${sheetTable(session)}
|
|
906
|
+
WHERE _key = ANY($1::text[])
|
|
907
|
+
ORDER BY _input_index ASC NULLS LAST, _created_at ASC, _key ASC`,
|
|
908
|
+
[keys],
|
|
909
|
+
);
|
|
910
|
+
return rows.map(mapRuntimePostgresRow);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function readRuntimeSummary(
|
|
914
|
+
session: RuntimePostgresSession,
|
|
915
|
+
): Promise<{ stats: { total: number } }> {
|
|
916
|
+
const normalizedPlayName = normalizePlayNameForSheet(session.playName);
|
|
917
|
+
const normalizedTableNamespace = normalizeTableNamespace(
|
|
918
|
+
session.target.tableNamespace,
|
|
919
|
+
);
|
|
920
|
+
return await withRuntimePostgres(session, async (client) => {
|
|
921
|
+
const { rows } = await client.query(
|
|
922
|
+
`SELECT total
|
|
923
|
+
FROM ${summaryTable(session)}
|
|
924
|
+
WHERE play_name = $1 AND table_namespace = $2
|
|
925
|
+
LIMIT 1`,
|
|
926
|
+
[normalizedPlayName, normalizedTableNamespace],
|
|
927
|
+
);
|
|
928
|
+
return { stats: { total: Number(rows[0]?.total ?? 0) } };
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async function writeRuntimeRows(
|
|
933
|
+
session: RuntimePostgresSession,
|
|
934
|
+
input: {
|
|
935
|
+
tableNamespace: string;
|
|
936
|
+
rows: Record<string, unknown>[];
|
|
937
|
+
runId: string;
|
|
938
|
+
idempotencyKey: string;
|
|
939
|
+
sheetContract: PlaySheetContract;
|
|
940
|
+
mode: 'append' | 'upsert' | 'replace';
|
|
941
|
+
},
|
|
942
|
+
): Promise<RowsWriteResponse> {
|
|
943
|
+
const physicalColumnProjections = physicalSheetColumnProjections(
|
|
944
|
+
input.sheetContract,
|
|
945
|
+
);
|
|
946
|
+
const physicalColumns = physicalColumnProjections.map(
|
|
947
|
+
(column) => column.sqlName,
|
|
948
|
+
);
|
|
949
|
+
const physicalInsertColumnsSql =
|
|
950
|
+
physicalColumns.length > 0
|
|
951
|
+
? `, ${physicalColumns.map(quoteIdentifier).join(', ')}`
|
|
952
|
+
: '';
|
|
953
|
+
const physicalInsertValuesSql =
|
|
954
|
+
physicalColumns.length > 0
|
|
955
|
+
? `, ${physicalColumnProjections
|
|
956
|
+
.map((column) => `payload -> ${quoteLiteral(column.fieldName)}`)
|
|
957
|
+
.join(', ')}`
|
|
958
|
+
: '';
|
|
959
|
+
|
|
960
|
+
const rowsToWrite = input.rows.filter(
|
|
961
|
+
(row) => row && typeof row === 'object' && !Array.isArray(row),
|
|
962
|
+
);
|
|
963
|
+
if (rowsToWrite.length === 0) {
|
|
964
|
+
return { disposition: 'completed', writtenRows: 0 };
|
|
965
|
+
}
|
|
966
|
+
const normalizedPlayName = normalizePlayNameForSheet(session.playName);
|
|
967
|
+
const normalizedTableNamespace = normalizeTableNamespace(
|
|
968
|
+
input.tableNamespace,
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
// Build write entries first so we know chunk count up front. Single-chunk
|
|
972
|
+
// writes skip the BEGIN/COMMIT pair entirely (the mega-CTE is atomic at
|
|
973
|
+
// statement level), and the per-chunk mega-CTE folds: starting-index lookup,
|
|
974
|
+
// sheet insert, event insert, and summary upsert into one round-trip.
|
|
975
|
+
const rowEntries =
|
|
976
|
+
input.mode === 'upsert'
|
|
977
|
+
? Array.from(
|
|
978
|
+
rowsToWrite
|
|
979
|
+
.reduce((uniqueRows, row) => {
|
|
980
|
+
const key = derivePlayRowIdentity(row, input.tableNamespace);
|
|
981
|
+
if (key && !uniqueRows.has(key)) {
|
|
982
|
+
uniqueRows.set(key, row);
|
|
983
|
+
}
|
|
984
|
+
return uniqueRows;
|
|
985
|
+
}, new Map<string, Record<string, unknown>>())
|
|
986
|
+
.entries(),
|
|
987
|
+
).map(([key, row], inputIndex) => ({ key, row, inputIndex }))
|
|
988
|
+
: rowsToWrite.map((row, index) => ({
|
|
989
|
+
key: buildAppendedRowKey({
|
|
990
|
+
row,
|
|
991
|
+
tableNamespace: input.tableNamespace,
|
|
992
|
+
idempotencyKey: input.idempotencyKey,
|
|
993
|
+
ordinal: index,
|
|
994
|
+
}),
|
|
995
|
+
row,
|
|
996
|
+
// For append/replace modes the starting offset is computed in SQL
|
|
997
|
+
// as `coalesce(max(_input_index), -1) + 1 + (ord - 1)`; we only
|
|
998
|
+
// pass the per-chunk ordinal here so the SQL can add it to the
|
|
999
|
+
// dynamic starting index without an extra round-trip.
|
|
1000
|
+
inputIndex: index,
|
|
1001
|
+
}));
|
|
1002
|
+
|
|
1003
|
+
const chunks = chunkValues(rowEntries, DIRECT_POSTGRES_BATCH_SIZE);
|
|
1004
|
+
const needsTransaction = input.mode === 'replace' || chunks.length > 1;
|
|
1005
|
+
|
|
1006
|
+
return await withRuntimePostgres(session, async (client) => {
|
|
1007
|
+
await ensureRuntimePhysicalSheetColumns(
|
|
1008
|
+
client,
|
|
1009
|
+
session,
|
|
1010
|
+
input.sheetContract,
|
|
1011
|
+
);
|
|
1012
|
+
if (needsTransaction) await client.query('BEGIN');
|
|
1013
|
+
try {
|
|
1014
|
+
if (input.mode === 'replace') {
|
|
1015
|
+
// Collapse 4 cleanup statements into one CTE-shaped query. Postgres
|
|
1016
|
+
// executes data-modifying CTEs against the same snapshot, but each
|
|
1017
|
+
// operates on a distinct table so ordering does not matter.
|
|
1018
|
+
await client.query(
|
|
1019
|
+
`WITH cleared_sheet AS (
|
|
1020
|
+
DELETE FROM ${sheetTable(session)} RETURNING 1
|
|
1021
|
+
),
|
|
1022
|
+
cleared_events AS (
|
|
1023
|
+
DELETE FROM ${eventTable(session)}
|
|
1024
|
+
WHERE play_name = $1 AND table_namespace = $2
|
|
1025
|
+
RETURNING 1
|
|
1026
|
+
),
|
|
1027
|
+
reset_summary AS (
|
|
1028
|
+
UPDATE ${summaryTable(session)}
|
|
1029
|
+
SET total = 0, queued = 0, running = 0, completed = 0, failed = 0, _updated_at = now()
|
|
1030
|
+
WHERE play_name = $1 AND table_namespace = $2
|
|
1031
|
+
RETURNING 1
|
|
1032
|
+
),
|
|
1033
|
+
cleared_col_summary AS (
|
|
1034
|
+
DELETE FROM ${columnSummaryTable(session)}
|
|
1035
|
+
WHERE play_name = $1 AND table_namespace = $2
|
|
1036
|
+
RETURNING 1
|
|
1037
|
+
)
|
|
1038
|
+
SELECT 1`,
|
|
1039
|
+
[normalizedPlayName, normalizedTableNamespace],
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// For append/replace: SQL computes _input_index from the live max so
|
|
1044
|
+
// we never need a separate SELECT round-trip. For upsert: the JS
|
|
1045
|
+
// ordinals (0..N) are passed straight through, matching prior shape.
|
|
1046
|
+
const computesStartingIndexInSql =
|
|
1047
|
+
input.mode === 'append' || input.mode === 'replace';
|
|
1048
|
+
|
|
1049
|
+
let writtenRows = 0;
|
|
1050
|
+
for (const chunk of chunks) {
|
|
1051
|
+
const chunkKeys = chunk.map((entry) => entry.key);
|
|
1052
|
+
const chunkPayloads = chunk.map((entry) => JSON.stringify(entry.row));
|
|
1053
|
+
const chunkInputIndexes = chunk.map((entry) => entry.inputIndex);
|
|
1054
|
+
|
|
1055
|
+
const startingIndexCte = computesStartingIndexInSql
|
|
1056
|
+
? `starting_index AS (
|
|
1057
|
+
SELECT coalesce(max(_input_index), -1)::bigint AS v FROM ${sheetTable(session)}
|
|
1058
|
+
),`
|
|
1059
|
+
: '';
|
|
1060
|
+
const inputIndexExpr = computesStartingIndexInSql
|
|
1061
|
+
? `(SELECT v FROM starting_index) + index_values._input_index::bigint + 1`
|
|
1062
|
+
: `index_values._input_index::bigint`;
|
|
1063
|
+
const insertedRowsCte =
|
|
1064
|
+
input.mode === 'upsert'
|
|
1065
|
+
? `inserted_rows AS (
|
|
1066
|
+
INSERT INTO ${sheetTable(session)} (_key, _status, _run_id, _input_index${physicalInsertColumnsSql})
|
|
1067
|
+
SELECT _key, 'pending', $4, _input_index${physicalInsertValuesSql}
|
|
1068
|
+
FROM input_rows
|
|
1069
|
+
ON CONFLICT (_key) DO UPDATE SET
|
|
1070
|
+
_status = 'pending',
|
|
1071
|
+
_run_id = EXCLUDED._run_id,
|
|
1072
|
+
_input_index = EXCLUDED._input_index
|
|
1073
|
+
WHERE ${sheetTable(session)}._status = 'stale'
|
|
1074
|
+
RETURNING _key
|
|
1075
|
+
),`
|
|
1076
|
+
: `inserted_rows AS (
|
|
1077
|
+
INSERT INTO ${sheetTable(session)} (_key, _status, _run_id, _input_index${physicalInsertColumnsSql})
|
|
1078
|
+
SELECT _key, 'pending', $4, _input_index${physicalInsertValuesSql}
|
|
1079
|
+
FROM input_rows
|
|
1080
|
+
RETURNING _key
|
|
1081
|
+
),`;
|
|
1082
|
+
const eventsWhere =
|
|
1083
|
+
input.mode === 'upsert'
|
|
1084
|
+
? `WHERE _key = ANY(SELECT _key FROM inserted_rows)`
|
|
1085
|
+
: '';
|
|
1086
|
+
|
|
1087
|
+
const sql = `
|
|
1088
|
+
WITH ${startingIndexCte}
|
|
1089
|
+
input_rows AS (
|
|
1090
|
+
SELECT key_values._key, payload_values.payload,
|
|
1091
|
+
${inputIndexExpr} AS _input_index
|
|
1092
|
+
FROM unnest($1::text[]) WITH ORDINALITY AS key_values(_key, ord)
|
|
1093
|
+
JOIN unnest($2::jsonb[]) WITH ORDINALITY AS payload_values(payload, ord)
|
|
1094
|
+
ON payload_values.ord = key_values.ord
|
|
1095
|
+
JOIN unnest($3::bigint[]) WITH ORDINALITY AS index_values(_input_index, ord)
|
|
1096
|
+
ON index_values.ord = key_values.ord
|
|
1097
|
+
),
|
|
1098
|
+
${insertedRowsCte}
|
|
1099
|
+
inserted_events AS (
|
|
1100
|
+
INSERT INTO ${eventTable(session)} (play_name, table_namespace, _key, _status, _run_id, _stage, data_patch)
|
|
1101
|
+
SELECT $5, $6, _key, 'pending', $4, 'queued', payload
|
|
1102
|
+
FROM input_rows
|
|
1103
|
+
${eventsWhere}
|
|
1104
|
+
RETURNING 1
|
|
1105
|
+
),
|
|
1106
|
+
inserted_count_cte AS (
|
|
1107
|
+
SELECT count(*)::bigint AS c FROM inserted_rows
|
|
1108
|
+
),
|
|
1109
|
+
summary_upsert AS (
|
|
1110
|
+
INSERT INTO ${summaryTable(session)} (play_name, table_namespace, total, queued, running, completed, failed)
|
|
1111
|
+
SELECT $5, $6, c, c, 0, 0, 0
|
|
1112
|
+
FROM inserted_count_cte
|
|
1113
|
+
WHERE c > 0
|
|
1114
|
+
ON CONFLICT (play_name, table_namespace) DO UPDATE SET
|
|
1115
|
+
total = ${summaryTable(session)}.total + EXCLUDED.total,
|
|
1116
|
+
queued = ${summaryTable(session)}.queued + EXCLUDED.queued,
|
|
1117
|
+
_updated_at = now()
|
|
1118
|
+
RETURNING 1
|
|
1119
|
+
)
|
|
1120
|
+
SELECT c::int AS inserted_count FROM inserted_count_cte
|
|
1121
|
+
`;
|
|
1122
|
+
|
|
1123
|
+
const { rows } = await client.query(sql, [
|
|
1124
|
+
chunkKeys,
|
|
1125
|
+
chunkPayloads,
|
|
1126
|
+
chunkInputIndexes,
|
|
1127
|
+
input.runId,
|
|
1128
|
+
normalizedPlayName,
|
|
1129
|
+
normalizedTableNamespace,
|
|
1130
|
+
]);
|
|
1131
|
+
writtenRows += Number(rows[0]?.inserted_count ?? 0);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (needsTransaction) await client.query('COMMIT');
|
|
1135
|
+
return { disposition: 'completed', writtenRows };
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
if (needsTransaction) {
|
|
1138
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
1139
|
+
}
|
|
1140
|
+
throw error;
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
export async function resolveRuntimeReferencedPlay(
|
|
1146
|
+
context: RuntimeApiContext,
|
|
1147
|
+
playRef: string,
|
|
1148
|
+
): Promise<ResolvedRuntimePlay | null> {
|
|
1149
|
+
const response = await postRuntimeApi<{ play: ResolvedRuntimePlay | null }>(
|
|
1150
|
+
context,
|
|
1151
|
+
{
|
|
1152
|
+
action: 'resolve_play',
|
|
1153
|
+
playRef,
|
|
1154
|
+
},
|
|
1155
|
+
);
|
|
1156
|
+
return response.play;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
export async function ensureRuntimeSheet(
|
|
1160
|
+
context: RuntimeApiContext,
|
|
1161
|
+
input: {
|
|
1162
|
+
playName: string;
|
|
1163
|
+
tableNamespace: string;
|
|
1164
|
+
sheetContract: PlaySheetContract;
|
|
1165
|
+
},
|
|
1166
|
+
): Promise<void> {
|
|
1167
|
+
await postRuntimeApi<{ ok: true }>(context, {
|
|
1168
|
+
action: 'ensure_sheet',
|
|
1169
|
+
...input,
|
|
1170
|
+
userEmail: normalizeRuntimeUserEmail(context.userEmail),
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
export async function startRuntimeSheetDataset(
|
|
1175
|
+
context: RuntimeApiContext,
|
|
1176
|
+
input: {
|
|
1177
|
+
playName: string;
|
|
1178
|
+
tableNamespace: string;
|
|
1179
|
+
playInput?: Record<string, unknown> | null;
|
|
1180
|
+
sheetContract: PlaySheetContract;
|
|
1181
|
+
rows: Record<string, unknown>[];
|
|
1182
|
+
runId: string;
|
|
1183
|
+
},
|
|
1184
|
+
): Promise<PrepareRuntimeSheetResult> {
|
|
1185
|
+
const playName = context.playName?.trim() || input.playName;
|
|
1186
|
+
if (!playName) {
|
|
1187
|
+
throw new Error('Runtime DB sessions require a playName.');
|
|
1188
|
+
}
|
|
1189
|
+
const session = requireRuntimePostgresSession(
|
|
1190
|
+
await getRuntimeDbSession(
|
|
1191
|
+
{
|
|
1192
|
+
...context,
|
|
1193
|
+
playName,
|
|
1194
|
+
},
|
|
1195
|
+
{
|
|
1196
|
+
tableNamespace: input.tableNamespace,
|
|
1197
|
+
logicalTable: 'sheet_rows',
|
|
1198
|
+
operations: ['rows.read', 'rows.upsert'],
|
|
1199
|
+
limits: {
|
|
1200
|
+
maxRows: Math.max(input.rows.length, 1),
|
|
1201
|
+
},
|
|
1202
|
+
sheetContract: input.sheetContract,
|
|
1203
|
+
},
|
|
1204
|
+
),
|
|
1205
|
+
);
|
|
1206
|
+
const uniqueRows = new Map<string, Record<string, unknown>>();
|
|
1207
|
+
for (const row of input.rows) {
|
|
1208
|
+
const { __deeplineRowKey: _rowKey, ...cleanedRow } = row;
|
|
1209
|
+
void _rowKey;
|
|
1210
|
+
const key =
|
|
1211
|
+
typeof row.__deeplineRowKey === 'string'
|
|
1212
|
+
? row.__deeplineRowKey
|
|
1213
|
+
: derivePlayRowIdentity(cleanedRow, input.tableNamespace);
|
|
1214
|
+
if (key && !uniqueRows.has(key)) {
|
|
1215
|
+
uniqueRows.set(key, cleanedRow);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
const rowEntries = [...uniqueRows.entries()].map(
|
|
1219
|
+
([key, row], inputIndex) => ({
|
|
1220
|
+
key,
|
|
1221
|
+
row,
|
|
1222
|
+
inputIndex,
|
|
1223
|
+
}),
|
|
1224
|
+
);
|
|
1225
|
+
if (rowEntries.length === 0) {
|
|
1226
|
+
return {
|
|
1227
|
+
inserted: 0,
|
|
1228
|
+
skipped: input.rows.length,
|
|
1229
|
+
pendingRows: [],
|
|
1230
|
+
completedRows: [],
|
|
1231
|
+
tableNamespace: input.tableNamespace,
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const physicalColumns = physicalSheetColumnNames(input.sheetContract);
|
|
1236
|
+
const physicalInsertColumnsSql =
|
|
1237
|
+
physicalColumns.length > 0
|
|
1238
|
+
? `, ${physicalColumns.map(quoteIdentifier).join(', ')}`
|
|
1239
|
+
: '';
|
|
1240
|
+
const physicalInsertValuesSql =
|
|
1241
|
+
physicalColumns.length > 0
|
|
1242
|
+
? `, ${physicalColumns
|
|
1243
|
+
.map((column) => `payload -> ${quoteLiteral(column)}`)
|
|
1244
|
+
.join(', ')}`
|
|
1245
|
+
: '';
|
|
1246
|
+
const normalizedPlayName = normalizePlayNameForSheet(playName);
|
|
1247
|
+
const normalizedTableNamespace = normalizeTableNamespace(
|
|
1248
|
+
input.tableNamespace,
|
|
1249
|
+
);
|
|
1250
|
+
|
|
1251
|
+
const chunks = chunkValues(rowEntries, DIRECT_POSTGRES_BATCH_SIZE);
|
|
1252
|
+
const needsTransaction = chunks.length > 1;
|
|
1253
|
+
|
|
1254
|
+
return await withRuntimePostgres(
|
|
1255
|
+
session,
|
|
1256
|
+
async (client) => {
|
|
1257
|
+
await ensureRuntimePhysicalSheetColumns(
|
|
1258
|
+
client,
|
|
1259
|
+
session,
|
|
1260
|
+
input.sheetContract,
|
|
1261
|
+
);
|
|
1262
|
+
if (needsTransaction) await client.query('BEGIN');
|
|
1263
|
+
try {
|
|
1264
|
+
let inserted = 0;
|
|
1265
|
+
const pendingKeys: string[] = [];
|
|
1266
|
+
for (const chunk of chunks) {
|
|
1267
|
+
const chunkKeys = chunk.map((entry) => entry.key);
|
|
1268
|
+
const chunkPayloads = chunk.map((entry) => JSON.stringify(entry.row));
|
|
1269
|
+
const chunkInputIndexes = chunk.map((entry) => entry.inputIndex);
|
|
1270
|
+
// Mega-CTE: row insert + event insert + status-derived pending set +
|
|
1271
|
+
// summary upsert in one round-trip. Saves 2 RTTs per chunk vs. the
|
|
1272
|
+
// prior "INSERT then summary INSERT then summary UPDATE" sequence.
|
|
1273
|
+
const { rows } = await client.query(
|
|
1274
|
+
`
|
|
1275
|
+
WITH input_rows AS (
|
|
1276
|
+
SELECT key_values._key, payload_values.payload, index_values._input_index
|
|
1277
|
+
FROM unnest($1::text[]) WITH ORDINALITY AS key_values(_key, ord)
|
|
1278
|
+
JOIN unnest($2::jsonb[]) WITH ORDINALITY AS payload_values(payload, ord)
|
|
1279
|
+
ON payload_values.ord = key_values.ord
|
|
1280
|
+
JOIN unnest($3::bigint[]) WITH ORDINALITY AS index_values(_input_index, ord)
|
|
1281
|
+
ON index_values.ord = key_values.ord
|
|
1282
|
+
),
|
|
1283
|
+
existing_rows AS (
|
|
1284
|
+
UPDATE ${sheetTable(session)} AS target
|
|
1285
|
+
SET _input_index = input_rows._input_index
|
|
1286
|
+
FROM input_rows
|
|
1287
|
+
WHERE target._key = input_rows._key
|
|
1288
|
+
AND target._input_index IS DISTINCT FROM input_rows._input_index
|
|
1289
|
+
RETURNING target._key
|
|
1290
|
+
),
|
|
1291
|
+
inserted_rows AS (
|
|
1292
|
+
INSERT INTO ${sheetTable(session)} (_key, _status, _run_id, _input_index${physicalInsertColumnsSql})
|
|
1293
|
+
SELECT _key, 'pending', $4, _input_index${physicalInsertValuesSql}
|
|
1294
|
+
FROM input_rows
|
|
1295
|
+
ON CONFLICT (_key) DO UPDATE SET
|
|
1296
|
+
_status = 'pending',
|
|
1297
|
+
_run_id = EXCLUDED._run_id,
|
|
1298
|
+
_input_index = EXCLUDED._input_index
|
|
1299
|
+
WHERE ${sheetTable(session)}._status = 'stale'
|
|
1300
|
+
RETURNING _key
|
|
1301
|
+
),
|
|
1302
|
+
inserted_events AS (
|
|
1303
|
+
INSERT INTO ${eventTable(session)} (play_name, table_namespace, _key, _status, _run_id, _stage, data_patch)
|
|
1304
|
+
SELECT $5, $6, _key, 'pending', $4, 'queued', payload
|
|
1305
|
+
FROM input_rows
|
|
1306
|
+
WHERE _key = ANY(SELECT _key FROM inserted_rows)
|
|
1307
|
+
RETURNING 1
|
|
1308
|
+
),
|
|
1309
|
+
pending_rows AS (
|
|
1310
|
+
SELECT _key
|
|
1311
|
+
FROM inserted_rows
|
|
1312
|
+
UNION
|
|
1313
|
+
SELECT existing._key
|
|
1314
|
+
FROM ${sheetTable(session)} AS existing
|
|
1315
|
+
JOIN input_rows ON input_rows._key = existing._key
|
|
1316
|
+
WHERE existing._status IN ('pending', 'running', 'failed')
|
|
1317
|
+
),
|
|
1318
|
+
inserted_count_cte AS (
|
|
1319
|
+
SELECT count(*)::bigint AS c FROM inserted_rows
|
|
1320
|
+
),
|
|
1321
|
+
summary_upsert AS (
|
|
1322
|
+
INSERT INTO ${summaryTable(session)} (play_name, table_namespace, total, queued, running, completed, failed)
|
|
1323
|
+
SELECT $5, $6, c, c, 0, 0, 0
|
|
1324
|
+
FROM inserted_count_cte
|
|
1325
|
+
WHERE c > 0
|
|
1326
|
+
ON CONFLICT (play_name, table_namespace) DO UPDATE SET
|
|
1327
|
+
total = ${summaryTable(session)}.total + EXCLUDED.total,
|
|
1328
|
+
queued = ${summaryTable(session)}.queued + EXCLUDED.queued,
|
|
1329
|
+
_updated_at = now()
|
|
1330
|
+
RETURNING 1
|
|
1331
|
+
)
|
|
1332
|
+
SELECT
|
|
1333
|
+
(SELECT c::int FROM inserted_count_cte) AS inserted_count,
|
|
1334
|
+
coalesce((SELECT array_agg(_key) FROM pending_rows), '{}'::text[]) AS pending_keys,
|
|
1335
|
+
(SELECT count(*)::int FROM existing_rows) AS reordered_count,
|
|
1336
|
+
(SELECT count(*)::int FROM inserted_events) AS event_count
|
|
1337
|
+
`,
|
|
1338
|
+
[
|
|
1339
|
+
chunkKeys,
|
|
1340
|
+
chunkPayloads,
|
|
1341
|
+
chunkInputIndexes,
|
|
1342
|
+
input.runId,
|
|
1343
|
+
normalizedPlayName,
|
|
1344
|
+
normalizedTableNamespace,
|
|
1345
|
+
],
|
|
1346
|
+
);
|
|
1347
|
+
inserted += Number(rows[0]?.inserted_count ?? 0);
|
|
1348
|
+
if (Array.isArray(rows[0]?.pending_keys)) {
|
|
1349
|
+
pendingKeys.push(...(rows[0]?.pending_keys as string[]));
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const pendingKeySet = new Set(pendingKeys);
|
|
1354
|
+
const pendingRows = rowEntries
|
|
1355
|
+
.filter((entry) => pendingKeySet.has(entry.key))
|
|
1356
|
+
.map((entry) => entry.row);
|
|
1357
|
+
const completedKeys = rowEntries
|
|
1358
|
+
.filter((entry) => !pendingKeySet.has(entry.key))
|
|
1359
|
+
.map((entry) => entry.key);
|
|
1360
|
+
const completedRowsByKey = new Map(
|
|
1361
|
+
(await readRuntimeRowsByKeys(client, session, completedKeys)).map(
|
|
1362
|
+
(row) => [row.key, row.data],
|
|
1363
|
+
),
|
|
1364
|
+
);
|
|
1365
|
+
const completedRows = rowEntries
|
|
1366
|
+
.filter((entry) => !pendingKeySet.has(entry.key))
|
|
1367
|
+
.map((entry) => ({
|
|
1368
|
+
...mergeRuntimeCompletedRow({
|
|
1369
|
+
inputRow: entry.row,
|
|
1370
|
+
completedData: completedRowsByKey.get(entry.key) ?? {},
|
|
1371
|
+
sheetContract: input.sheetContract,
|
|
1372
|
+
}),
|
|
1373
|
+
__deeplineRowKey: entry.key,
|
|
1374
|
+
}));
|
|
1375
|
+
if (needsTransaction) await client.query('COMMIT');
|
|
1376
|
+
return {
|
|
1377
|
+
inserted,
|
|
1378
|
+
skipped:
|
|
1379
|
+
rowEntries.length -
|
|
1380
|
+
inserted +
|
|
1381
|
+
(input.rows.length - rowEntries.length),
|
|
1382
|
+
pendingRows,
|
|
1383
|
+
completedRows,
|
|
1384
|
+
tableNamespace: input.tableNamespace,
|
|
1385
|
+
};
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
if (needsTransaction) {
|
|
1388
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
1389
|
+
}
|
|
1390
|
+
throw error;
|
|
1391
|
+
}
|
|
1392
|
+
},
|
|
1393
|
+
{ cachePool: !context.disablePostgresPoolCache },
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Mark map rows completed in the per-run scoped Postgres sheet table by key.
|
|
1399
|
+
* Mirrors server-side `store.completeSheetRows` semantics: UPDATE-by-key with
|
|
1400
|
+
* the row's enriched output values written into materialized physical
|
|
1401
|
+
* columns, _status flipped to 'enriched', and a completion event row appended.
|
|
1402
|
+
*
|
|
1403
|
+
* Used by the workers_edge harness's `persistCompletedMapRows` so map
|
|
1404
|
+
* completion writes go directly to Neon (via @neondatabase/serverless) and
|
|
1405
|
+
* skip the per-chunk Vercel hop.
|
|
1406
|
+
*/
|
|
1407
|
+
export async function completeRuntimeMapRows(
|
|
1408
|
+
context: RuntimeApiContext & { playName: string },
|
|
1409
|
+
input: {
|
|
1410
|
+
tableNamespace: string;
|
|
1411
|
+
sheetContract: PlaySheetContract;
|
|
1412
|
+
rows: RuntimeApiRowRecord[];
|
|
1413
|
+
runId: string;
|
|
1414
|
+
},
|
|
1415
|
+
): Promise<{ updated: number }> {
|
|
1416
|
+
if (input.rows.length === 0) {
|
|
1417
|
+
return { updated: 0 };
|
|
1418
|
+
}
|
|
1419
|
+
const session = requireRuntimePostgresSession(
|
|
1420
|
+
await getRuntimeDbSession(
|
|
1421
|
+
{ ...context, playName: context.playName },
|
|
1422
|
+
{
|
|
1423
|
+
tableNamespace: input.tableNamespace,
|
|
1424
|
+
logicalTable: 'sheet_rows',
|
|
1425
|
+
operations: ['rows.read', 'rows.upsert'],
|
|
1426
|
+
limits: {
|
|
1427
|
+
maxRows: Math.max(input.rows.length, 1),
|
|
1428
|
+
},
|
|
1429
|
+
sheetContract: input.sheetContract,
|
|
1430
|
+
},
|
|
1431
|
+
),
|
|
1432
|
+
);
|
|
1433
|
+
|
|
1434
|
+
// Dedupe by key — duplicates within the same chunk are idempotent on the
|
|
1435
|
+
// sheet, but they bloat the events table. Last write wins on payload.
|
|
1436
|
+
const uniqueRows = new Map<string, Record<string, unknown>>();
|
|
1437
|
+
const cellMetaPatchByKey = new Map<string, Record<string, unknown>>();
|
|
1438
|
+
for (const row of input.rows) {
|
|
1439
|
+
if (!row.key) continue;
|
|
1440
|
+
uniqueRows.set(row.key, row.data);
|
|
1441
|
+
if (row.cellMetaPatch) {
|
|
1442
|
+
cellMetaPatchByKey.set(row.key, row.cellMetaPatch);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
if (uniqueRows.size === 0) {
|
|
1446
|
+
return { updated: 0 };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const projections = physicalSheetColumnProjections(input.sheetContract);
|
|
1450
|
+
const physicalUpdateSetSql =
|
|
1451
|
+
projections.length > 0
|
|
1452
|
+
? `,\n ${projections
|
|
1453
|
+
.map((column) => {
|
|
1454
|
+
const quoted = quoteIdentifier(column.sqlName);
|
|
1455
|
+
const literal = quoteLiteral(column.fieldName);
|
|
1456
|
+
return `${quoted} = CASE
|
|
1457
|
+
WHEN coalesce(updates.data_patch, '{}'::jsonb) ? ${literal}
|
|
1458
|
+
THEN updates.data_patch -> ${literal}
|
|
1459
|
+
ELSE target.${quoted}
|
|
1460
|
+
END`;
|
|
1461
|
+
})
|
|
1462
|
+
.join(',\n ')}`
|
|
1463
|
+
: '';
|
|
1464
|
+
|
|
1465
|
+
const normalizedPlayName = normalizePlayNameForSheet(session.playName);
|
|
1466
|
+
const normalizedTableNamespace = normalizeTableNamespace(
|
|
1467
|
+
input.tableNamespace,
|
|
1468
|
+
);
|
|
1469
|
+
const completedRows = [...uniqueRows.entries()].map(([key, data]) => ({
|
|
1470
|
+
key,
|
|
1471
|
+
data_patch: data,
|
|
1472
|
+
cell_meta_patch: cellMetaPatchByKey.get(key) ?? {},
|
|
1473
|
+
}));
|
|
1474
|
+
const chunks = chunkValues(completedRows, DIRECT_POSTGRES_BATCH_SIZE);
|
|
1475
|
+
const needsTransaction = chunks.length > 1;
|
|
1476
|
+
|
|
1477
|
+
return await withRuntimePostgres(
|
|
1478
|
+
session,
|
|
1479
|
+
async (client) => {
|
|
1480
|
+
await ensureRuntimePhysicalSheetColumns(
|
|
1481
|
+
client,
|
|
1482
|
+
session,
|
|
1483
|
+
input.sheetContract,
|
|
1484
|
+
);
|
|
1485
|
+
if (needsTransaction) await client.query('BEGIN');
|
|
1486
|
+
let updated = 0;
|
|
1487
|
+
try {
|
|
1488
|
+
for (const chunk of chunks) {
|
|
1489
|
+
const { rows: appliedRows } = await client.query<{ _key: string }>(
|
|
1490
|
+
`WITH updates AS (
|
|
1491
|
+
SELECT * FROM jsonb_to_recordset($1::jsonb) AS incoming(
|
|
1492
|
+
key text,
|
|
1493
|
+
data_patch jsonb,
|
|
1494
|
+
cell_meta_patch jsonb
|
|
1495
|
+
)
|
|
1496
|
+
),
|
|
1497
|
+
applied_rows AS (
|
|
1498
|
+
UPDATE ${sheetTable(session)} AS target
|
|
1499
|
+
SET _status = 'enriched',
|
|
1500
|
+
_run_id = $4,
|
|
1501
|
+
_error = NULL,
|
|
1502
|
+
_updated_at = now(),
|
|
1503
|
+
_cell_meta = coalesce(target._cell_meta, '{}'::jsonb)
|
|
1504
|
+
|| coalesce(updates.cell_meta_patch, '{}'::jsonb)${physicalUpdateSetSql}
|
|
1505
|
+
FROM updates
|
|
1506
|
+
WHERE target._key = updates.key
|
|
1507
|
+
RETURNING target._key
|
|
1508
|
+
),
|
|
1509
|
+
inserted_events AS (
|
|
1510
|
+
INSERT INTO ${eventTable(session)}
|
|
1511
|
+
(play_name, table_namespace, _key, _status, _run_id, _stage, data_patch)
|
|
1512
|
+
SELECT $2, $3, updates.key, 'enriched', $4, 'completed',
|
|
1513
|
+
coalesce(updates.data_patch, '{}'::jsonb)
|
|
1514
|
+
FROM updates
|
|
1515
|
+
WHERE updates.key = ANY(SELECT _key FROM applied_rows)
|
|
1516
|
+
RETURNING 1
|
|
1517
|
+
)
|
|
1518
|
+
SELECT _key FROM applied_rows`,
|
|
1519
|
+
[
|
|
1520
|
+
JSON.stringify(chunk),
|
|
1521
|
+
normalizedPlayName,
|
|
1522
|
+
normalizedTableNamespace,
|
|
1523
|
+
input.runId,
|
|
1524
|
+
],
|
|
1525
|
+
);
|
|
1526
|
+
updated += appliedRows.length;
|
|
1527
|
+
}
|
|
1528
|
+
if (needsTransaction) await client.query('COMMIT');
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
if (needsTransaction) {
|
|
1531
|
+
await client.query('ROLLBACK').catch(() => undefined);
|
|
1532
|
+
}
|
|
1533
|
+
throw error;
|
|
1534
|
+
}
|
|
1535
|
+
return { updated };
|
|
1536
|
+
},
|
|
1537
|
+
{ cachePool: !context.disablePostgresPoolCache },
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
export async function persistRuntimeCsvDataset(
|
|
1542
|
+
context: RuntimeApiContext & { playName: string },
|
|
1543
|
+
input: {
|
|
1544
|
+
tableNamespace: string;
|
|
1545
|
+
playInput?: Record<string, unknown> | null;
|
|
1546
|
+
sheetContract: PlaySheetContract;
|
|
1547
|
+
rows: Record<string, unknown>[];
|
|
1548
|
+
runId: string;
|
|
1549
|
+
sourceLabel?: string | null;
|
|
1550
|
+
},
|
|
1551
|
+
): Promise<PlayDataset<Record<string, unknown>>> {
|
|
1552
|
+
await startRuntimeSheetDataset(context, {
|
|
1553
|
+
playName: context.playName,
|
|
1554
|
+
tableNamespace: input.tableNamespace,
|
|
1555
|
+
playInput: input.playInput,
|
|
1556
|
+
sheetContract: input.sheetContract,
|
|
1557
|
+
rows: input.rows,
|
|
1558
|
+
runId: input.runId,
|
|
1559
|
+
});
|
|
1560
|
+
return createRuntimeBackedPlayDataset({
|
|
1561
|
+
context,
|
|
1562
|
+
tableNamespace: input.tableNamespace,
|
|
1563
|
+
datasetKind: 'csv',
|
|
1564
|
+
sourceLabel: input.sourceLabel,
|
|
1565
|
+
initialCount: input.rows.length,
|
|
1566
|
+
initialPreviewRows: input.rows.slice(0, 10),
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
export async function upsertRuntimeRows(
|
|
1571
|
+
context: RuntimeApiContext,
|
|
1572
|
+
input: {
|
|
1573
|
+
playName: string;
|
|
1574
|
+
tableNamespace: string;
|
|
1575
|
+
rows: Record<string, unknown>[];
|
|
1576
|
+
runId: string;
|
|
1577
|
+
sheetContract: PlaySheetContract;
|
|
1578
|
+
skipEnsureSheet?: boolean;
|
|
1579
|
+
},
|
|
1580
|
+
): Promise<RowsWriteResponse> {
|
|
1581
|
+
if (!context.playName) {
|
|
1582
|
+
throw new Error('Runtime DB sessions require a playName.');
|
|
1583
|
+
}
|
|
1584
|
+
const session = await getRuntimeDbSession(
|
|
1585
|
+
{ ...context, playName: context.playName },
|
|
1586
|
+
{
|
|
1587
|
+
tableNamespace: input.tableNamespace,
|
|
1588
|
+
logicalTable: 'sheet_rows',
|
|
1589
|
+
operations: ['rows.upsert'],
|
|
1590
|
+
limits: {
|
|
1591
|
+
maxRows: Math.max(input.rows.length, 1),
|
|
1592
|
+
},
|
|
1593
|
+
sheetContract: input.sheetContract,
|
|
1594
|
+
},
|
|
1595
|
+
);
|
|
1596
|
+
return rowsWriteResponseSchema.parse(
|
|
1597
|
+
await writeRuntimeRows(requireRuntimePostgresSession(session), {
|
|
1598
|
+
tableNamespace: input.tableNamespace,
|
|
1599
|
+
rows: input.rows,
|
|
1600
|
+
runId: input.runId,
|
|
1601
|
+
sheetContract: input.sheetContract,
|
|
1602
|
+
idempotencyKey: createHash('sha1')
|
|
1603
|
+
.update(
|
|
1604
|
+
`${input.playName}:${input.tableNamespace}:${input.runId}:${JSON.stringify(input.rows)}`,
|
|
1605
|
+
)
|
|
1606
|
+
.digest('hex'),
|
|
1607
|
+
mode: 'upsert',
|
|
1608
|
+
}),
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
export async function appendRuntimeRows(
|
|
1613
|
+
context: RuntimeApiContext,
|
|
1614
|
+
input: {
|
|
1615
|
+
playName: string;
|
|
1616
|
+
tableNamespace: string;
|
|
1617
|
+
rows: Record<string, unknown>[];
|
|
1618
|
+
runId: string;
|
|
1619
|
+
idempotencyKey: string;
|
|
1620
|
+
sheetContract: PlaySheetContract;
|
|
1621
|
+
},
|
|
1622
|
+
): Promise<RowsWriteResponse> {
|
|
1623
|
+
if (!context.playName) {
|
|
1624
|
+
throw new Error('Runtime DB sessions require a playName.');
|
|
1625
|
+
}
|
|
1626
|
+
const session = await getRuntimeDbSession(
|
|
1627
|
+
{ ...context, playName: context.playName },
|
|
1628
|
+
{
|
|
1629
|
+
tableNamespace: input.tableNamespace,
|
|
1630
|
+
logicalTable: 'sheet_rows',
|
|
1631
|
+
operations: ['rows.append'],
|
|
1632
|
+
limits: {
|
|
1633
|
+
maxRows: Math.max(input.rows.length, 1),
|
|
1634
|
+
},
|
|
1635
|
+
sheetContract: input.sheetContract,
|
|
1636
|
+
},
|
|
1637
|
+
);
|
|
1638
|
+
return rowsWriteResponseSchema.parse(
|
|
1639
|
+
await writeRuntimeRows(requireRuntimePostgresSession(session), {
|
|
1640
|
+
tableNamespace: input.tableNamespace,
|
|
1641
|
+
rows: input.rows,
|
|
1642
|
+
runId: input.runId,
|
|
1643
|
+
sheetContract: input.sheetContract,
|
|
1644
|
+
idempotencyKey: input.idempotencyKey,
|
|
1645
|
+
mode: 'append',
|
|
1646
|
+
}),
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
export async function replaceRuntimeRows(
|
|
1651
|
+
context: RuntimeApiContext,
|
|
1652
|
+
input: {
|
|
1653
|
+
playName: string;
|
|
1654
|
+
tableNamespace: string;
|
|
1655
|
+
rows: Record<string, unknown>[];
|
|
1656
|
+
runId: string;
|
|
1657
|
+
idempotencyKey: string;
|
|
1658
|
+
sheetContract: PlaySheetContract;
|
|
1659
|
+
},
|
|
1660
|
+
): Promise<RowsWriteResponse> {
|
|
1661
|
+
if (!context.playName) {
|
|
1662
|
+
throw new Error('Runtime DB sessions require a playName.');
|
|
1663
|
+
}
|
|
1664
|
+
const session = await getRuntimeDbSession(
|
|
1665
|
+
{ ...context, playName: context.playName },
|
|
1666
|
+
{
|
|
1667
|
+
tableNamespace: input.tableNamespace,
|
|
1668
|
+
logicalTable: 'sheet_rows',
|
|
1669
|
+
operations: ['rows.replace'],
|
|
1670
|
+
limits: {
|
|
1671
|
+
maxRows: Math.max(input.rows.length, 1),
|
|
1672
|
+
},
|
|
1673
|
+
sheetContract: input.sheetContract,
|
|
1674
|
+
},
|
|
1675
|
+
);
|
|
1676
|
+
return rowsWriteResponseSchema.parse(
|
|
1677
|
+
await writeRuntimeRows(requireRuntimePostgresSession(session), {
|
|
1678
|
+
tableNamespace: input.tableNamespace,
|
|
1679
|
+
rows: input.rows,
|
|
1680
|
+
runId: input.runId,
|
|
1681
|
+
sheetContract: input.sheetContract,
|
|
1682
|
+
idempotencyKey: input.idempotencyKey,
|
|
1683
|
+
mode: 'replace',
|
|
1684
|
+
}),
|
|
1685
|
+
);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
export function createRuntimeBackedPlayDataset(input: {
|
|
1689
|
+
context: RuntimeApiContext & {
|
|
1690
|
+
playName: string;
|
|
1691
|
+
};
|
|
1692
|
+
tableNamespace: string;
|
|
1693
|
+
datasetKind: 'csv' | 'map';
|
|
1694
|
+
sourceLabel?: string | null;
|
|
1695
|
+
initialCount?: number;
|
|
1696
|
+
initialPreviewRows?: Record<string, unknown>[];
|
|
1697
|
+
}): PlayDataset<Record<string, unknown>> {
|
|
1698
|
+
const csvRows =
|
|
1699
|
+
input.datasetKind === 'csv' &&
|
|
1700
|
+
(input.initialPreviewRows?.length ?? 0) >= (input.initialCount ?? 0)
|
|
1701
|
+
? [...(input.initialPreviewRows ?? [])]
|
|
1702
|
+
: null;
|
|
1703
|
+
if (csvRows) {
|
|
1704
|
+
return createDeferredPlayDataset({
|
|
1705
|
+
datasetKind: input.datasetKind,
|
|
1706
|
+
datasetId: createRuntimeDatasetId(
|
|
1707
|
+
input.context.playName,
|
|
1708
|
+
input.tableNamespace,
|
|
1709
|
+
),
|
|
1710
|
+
count: input.initialCount ?? csvRows.length,
|
|
1711
|
+
previewRows: csvRows.slice(0, 10),
|
|
1712
|
+
sourceLabel: input.sourceLabel ?? null,
|
|
1713
|
+
resolvers: {
|
|
1714
|
+
count: async () => csvRows.length,
|
|
1715
|
+
peek: async (limit) => csvRows.slice(0, Math.max(0, limit)),
|
|
1716
|
+
materialize: async (limit) =>
|
|
1717
|
+
limit === undefined
|
|
1718
|
+
? [...csvRows]
|
|
1719
|
+
: csvRows.slice(0, Math.max(0, limit)),
|
|
1720
|
+
iterate: () =>
|
|
1721
|
+
({
|
|
1722
|
+
async *[Symbol.asyncIterator]() {
|
|
1723
|
+
for (const row of csvRows) {
|
|
1724
|
+
yield row;
|
|
1725
|
+
}
|
|
1726
|
+
},
|
|
1727
|
+
}) as AsyncIterable<Record<string, unknown>>,
|
|
1728
|
+
},
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
return createDeferredPlayDataset({
|
|
1732
|
+
datasetKind: input.datasetKind,
|
|
1733
|
+
datasetId: createRuntimeDatasetId(
|
|
1734
|
+
input.context.playName,
|
|
1735
|
+
input.tableNamespace,
|
|
1736
|
+
),
|
|
1737
|
+
count: input.initialCount ?? 0,
|
|
1738
|
+
backing: {
|
|
1739
|
+
storage: 'neon_sheet',
|
|
1740
|
+
sheet: {
|
|
1741
|
+
playName: input.context.playName,
|
|
1742
|
+
tableNamespace: input.tableNamespace,
|
|
1743
|
+
},
|
|
1744
|
+
},
|
|
1745
|
+
previewRows: input.initialPreviewRows ?? [],
|
|
1746
|
+
sourceLabel: input.sourceLabel ?? null,
|
|
1747
|
+
tableNamespace: input.tableNamespace,
|
|
1748
|
+
resolvers: {
|
|
1749
|
+
count: async () => {
|
|
1750
|
+
const summarySession = await getRuntimeDbSession(input.context, {
|
|
1751
|
+
tableNamespace: input.tableNamespace,
|
|
1752
|
+
logicalTable: 'sheet_rows',
|
|
1753
|
+
operations: ['rows.read'],
|
|
1754
|
+
});
|
|
1755
|
+
const summary = await readRuntimeSummary(
|
|
1756
|
+
requireRuntimePostgresSession(summarySession),
|
|
1757
|
+
);
|
|
1758
|
+
return Number(summary.stats.total ?? 0);
|
|
1759
|
+
},
|
|
1760
|
+
peek: async (limit) => {
|
|
1761
|
+
const session = await getRuntimeDbSession(input.context, {
|
|
1762
|
+
tableNamespace: input.tableNamespace,
|
|
1763
|
+
logicalTable: 'sheet_rows',
|
|
1764
|
+
operations: ['rows.read'],
|
|
1765
|
+
});
|
|
1766
|
+
const rows = await readRuntimeRows(
|
|
1767
|
+
requireRuntimePostgresSession(session),
|
|
1768
|
+
{
|
|
1769
|
+
limit,
|
|
1770
|
+
offset: 0,
|
|
1771
|
+
},
|
|
1772
|
+
);
|
|
1773
|
+
return rows.map((row) => row.data);
|
|
1774
|
+
},
|
|
1775
|
+
materialize: async (limit) => {
|
|
1776
|
+
const pageSize = 1000;
|
|
1777
|
+
const materialized: Record<string, unknown>[] = [];
|
|
1778
|
+
let offset = 0;
|
|
1779
|
+
while (true) {
|
|
1780
|
+
const session = await getRuntimeDbSession(input.context, {
|
|
1781
|
+
tableNamespace: input.tableNamespace,
|
|
1782
|
+
logicalTable: 'sheet_rows',
|
|
1783
|
+
operations: ['rows.read'],
|
|
1784
|
+
});
|
|
1785
|
+
const rows = await readRuntimeRows(
|
|
1786
|
+
requireRuntimePostgresSession(session),
|
|
1787
|
+
{
|
|
1788
|
+
limit:
|
|
1789
|
+
limit !== undefined
|
|
1790
|
+
? Math.min(pageSize, Math.max(0, limit - materialized.length))
|
|
1791
|
+
: pageSize,
|
|
1792
|
+
offset,
|
|
1793
|
+
},
|
|
1794
|
+
);
|
|
1795
|
+
if (rows.length === 0) {
|
|
1796
|
+
break;
|
|
1797
|
+
}
|
|
1798
|
+
materialized.push(...rows.map((row) => row.data));
|
|
1799
|
+
if (limit !== undefined && materialized.length >= limit) {
|
|
1800
|
+
return materialized.slice(0, limit);
|
|
1801
|
+
}
|
|
1802
|
+
offset += rows.length;
|
|
1803
|
+
}
|
|
1804
|
+
return materialized;
|
|
1805
|
+
},
|
|
1806
|
+
iterate: () =>
|
|
1807
|
+
({
|
|
1808
|
+
async *[Symbol.asyncIterator]() {
|
|
1809
|
+
let afterInputIndex = -1;
|
|
1810
|
+
while (true) {
|
|
1811
|
+
const session = await getRuntimeDbSession(input.context, {
|
|
1812
|
+
tableNamespace: input.tableNamespace,
|
|
1813
|
+
logicalTable: 'sheet_rows',
|
|
1814
|
+
operations: ['rows.read'],
|
|
1815
|
+
});
|
|
1816
|
+
const page = await readRuntimeRowsPage(
|
|
1817
|
+
requireRuntimePostgresSession(session),
|
|
1818
|
+
{
|
|
1819
|
+
afterInputIndex,
|
|
1820
|
+
limit: 1000,
|
|
1821
|
+
statuses: ['pending', 'completed', 'failed'],
|
|
1822
|
+
},
|
|
1823
|
+
);
|
|
1824
|
+
if (page.length === 0) {
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
for (const row of page) {
|
|
1828
|
+
afterInputIndex = Math.max(
|
|
1829
|
+
afterInputIndex,
|
|
1830
|
+
Number(row.inputIndex ?? afterInputIndex),
|
|
1831
|
+
);
|
|
1832
|
+
yield row.data;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
},
|
|
1836
|
+
}) as AsyncIterable<Record<string, unknown>>,
|
|
1837
|
+
},
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
export function resolveRuntimeSheetContract(
|
|
1842
|
+
pipeline: PlayStaticPipeline | null | undefined,
|
|
1843
|
+
tableNamespace: string | null | undefined,
|
|
1844
|
+
): PlaySheetContract | null {
|
|
1845
|
+
const requestedNamespace = tableNamespace?.trim();
|
|
1846
|
+
if (!pipeline || !requestedNamespace) {
|
|
1847
|
+
return null;
|
|
1848
|
+
}
|
|
1849
|
+
const normalizedNamespace = normalizeTableNamespace(requestedNamespace);
|
|
1850
|
+
|
|
1851
|
+
const rootNamespace = pipeline.tableNamespace?.trim();
|
|
1852
|
+
if (
|
|
1853
|
+
rootNamespace &&
|
|
1854
|
+
normalizeTableNamespace(rootNamespace) === normalizedNamespace
|
|
1855
|
+
) {
|
|
1856
|
+
return pipeline.sheetContract ?? null;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
for (const substep of [...(pipeline.stages ?? []), ...pipeline.substeps]) {
|
|
1860
|
+
if (substep.type !== 'map') {
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
const substepNamespace = substep.tableNamespace?.trim();
|
|
1864
|
+
if (
|
|
1865
|
+
substepNamespace &&
|
|
1866
|
+
normalizeTableNamespace(substepNamespace) === normalizedNamespace
|
|
1867
|
+
) {
|
|
1868
|
+
return substep.sheetContract ?? null;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
return null;
|
|
1873
|
+
}
|