deepline 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +324 -0
  2. package/dist/cli/index.js +6750 -503
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/cli/index.mjs +6735 -512
  5. package/dist/cli/index.mjs.map +1 -1
  6. package/dist/index.d.mts +2349 -32
  7. package/dist/index.d.ts +2349 -32
  8. package/dist/index.js +1631 -82
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.mjs +1617 -83
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/repo/apps/play-runner-workers/src/coordinator-entry.ts +3256 -0
  13. package/dist/repo/apps/play-runner-workers/src/dedup-do.ts +710 -0
  14. package/dist/repo/apps/play-runner-workers/src/entry.ts +5070 -0
  15. package/dist/repo/apps/play-runner-workers/src/runtime/README.md +21 -0
  16. package/dist/repo/apps/play-runner-workers/src/runtime/batching.ts +177 -0
  17. package/dist/repo/apps/play-runner-workers/src/runtime/execution-plan.ts +52 -0
  18. package/dist/repo/apps/play-runner-workers/src/runtime/tool-batch.ts +100 -0
  19. package/dist/repo/apps/play-runner-workers/src/runtime/tool-result.ts +184 -0
  20. package/dist/repo/sdk/src/cli/commands/auth.ts +482 -0
  21. package/dist/repo/sdk/src/cli/commands/billing.ts +188 -0
  22. package/dist/repo/sdk/src/cli/commands/csv.ts +123 -0
  23. package/dist/repo/sdk/src/cli/commands/db.ts +119 -0
  24. package/dist/repo/sdk/src/cli/commands/feedback.ts +40 -0
  25. package/dist/repo/sdk/src/cli/commands/org.ts +117 -0
  26. package/dist/repo/sdk/src/cli/commands/play.ts +3200 -0
  27. package/dist/repo/sdk/src/cli/commands/tools.ts +687 -0
  28. package/dist/repo/sdk/src/cli/dataset-stats.ts +341 -0
  29. package/dist/repo/sdk/src/cli/index.ts +138 -0
  30. package/dist/repo/sdk/src/cli/progress.ts +135 -0
  31. package/dist/repo/sdk/src/cli/trace.ts +61 -0
  32. package/dist/repo/sdk/src/cli/utils.ts +145 -0
  33. package/dist/repo/sdk/src/client.ts +1188 -0
  34. package/dist/repo/sdk/src/compat.ts +77 -0
  35. package/dist/repo/sdk/src/config.ts +285 -0
  36. package/dist/repo/sdk/src/errors.ts +125 -0
  37. package/dist/repo/sdk/src/http.ts +391 -0
  38. package/dist/repo/sdk/src/index.ts +139 -0
  39. package/dist/repo/sdk/src/play.ts +1330 -0
  40. package/dist/repo/sdk/src/plays/bundle-play-file.ts +133 -0
  41. package/dist/repo/sdk/src/plays/harness-stub.ts +210 -0
  42. package/dist/repo/sdk/src/plays/local-file-discovery.ts +326 -0
  43. package/dist/repo/sdk/src/tool-output.ts +489 -0
  44. package/dist/repo/sdk/src/types.ts +669 -0
  45. package/dist/repo/sdk/src/version.ts +2 -0
  46. package/dist/repo/sdk/src/worker-play-entry.ts +286 -0
  47. package/dist/repo/shared_libs/observability/node-tracing.ts +129 -0
  48. package/dist/repo/shared_libs/observability/tracing.ts +98 -0
  49. package/dist/repo/shared_libs/play-runtime/backend.ts +139 -0
  50. package/dist/repo/shared_libs/play-runtime/batch-runtime.ts +182 -0
  51. package/dist/repo/shared_libs/play-runtime/batching-types.ts +91 -0
  52. package/dist/repo/shared_libs/play-runtime/context.ts +3999 -0
  53. package/dist/repo/shared_libs/play-runtime/coordinator-headers.ts +78 -0
  54. package/dist/repo/shared_libs/play-runtime/ctx-contract.ts +250 -0
  55. package/dist/repo/shared_libs/play-runtime/ctx-types.ts +713 -0
  56. package/dist/repo/shared_libs/play-runtime/dataset-id.ts +10 -0
  57. package/dist/repo/shared_libs/play-runtime/db-session-crypto.ts +304 -0
  58. package/dist/repo/shared_libs/play-runtime/db-session.ts +462 -0
  59. package/dist/repo/shared_libs/play-runtime/dedup-backend.ts +0 -0
  60. package/dist/repo/shared_libs/play-runtime/default-batch-strategies.ts +124 -0
  61. package/dist/repo/shared_libs/play-runtime/execution-plan.ts +262 -0
  62. package/dist/repo/shared_libs/play-runtime/live-events.ts +214 -0
  63. package/dist/repo/shared_libs/play-runtime/live-state-contract.ts +50 -0
  64. package/dist/repo/shared_libs/play-runtime/map-execution-frame.ts +114 -0
  65. package/dist/repo/shared_libs/play-runtime/map-row-identity.ts +158 -0
  66. package/dist/repo/shared_libs/play-runtime/profiles.ts +90 -0
  67. package/dist/repo/shared_libs/play-runtime/progress-emitter.ts +172 -0
  68. package/dist/repo/shared_libs/play-runtime/protocol.ts +121 -0
  69. package/dist/repo/shared_libs/play-runtime/public-play-contract.ts +42 -0
  70. package/dist/repo/shared_libs/play-runtime/result-normalization.ts +33 -0
  71. package/dist/repo/shared_libs/play-runtime/runtime-actions.ts +208 -0
  72. package/dist/repo/shared_libs/play-runtime/runtime-api.ts +1873 -0
  73. package/dist/repo/shared_libs/play-runtime/runtime-constraints.ts +2 -0
  74. package/dist/repo/shared_libs/play-runtime/runtime-pg-driver-neon-serverless.ts +201 -0
  75. package/dist/repo/shared_libs/play-runtime/runtime-pg-driver-pg.ts +48 -0
  76. package/dist/repo/shared_libs/play-runtime/runtime-pg-driver.ts +84 -0
  77. package/dist/repo/shared_libs/play-runtime/scheduler-backend.ts +174 -0
  78. package/dist/repo/shared_libs/play-runtime/static-pipeline-types.ts +147 -0
  79. package/dist/repo/shared_libs/play-runtime/suspension.ts +68 -0
  80. package/dist/repo/shared_libs/play-runtime/tool-batch-executor.ts +146 -0
  81. package/dist/repo/shared_libs/play-runtime/tool-result.ts +387 -0
  82. package/dist/repo/shared_libs/play-runtime/tracing.ts +31 -0
  83. package/dist/repo/shared_libs/play-runtime/waterfall-replay.ts +75 -0
  84. package/dist/repo/shared_libs/play-runtime/worker-api-types.ts +140 -0
  85. package/dist/repo/shared_libs/plays/artifact-transport.ts +14 -0
  86. package/dist/repo/shared_libs/plays/artifact-types.ts +49 -0
  87. package/dist/repo/shared_libs/plays/bundling/index.ts +1346 -0
  88. package/dist/repo/shared_libs/plays/compiler-manifest.ts +186 -0
  89. package/dist/repo/shared_libs/plays/contracts.ts +51 -0
  90. package/dist/repo/shared_libs/plays/dataset.ts +308 -0
  91. package/dist/repo/shared_libs/plays/definition.ts +264 -0
  92. package/dist/repo/shared_libs/plays/file-refs.ts +11 -0
  93. package/dist/repo/shared_libs/plays/rate-limit-scheduler.ts +206 -0
  94. package/dist/repo/shared_libs/plays/resolve-static-pipeline.ts +164 -0
  95. package/dist/repo/shared_libs/plays/row-identity.ts +302 -0
  96. package/dist/repo/shared_libs/plays/runtime-validation.ts +415 -0
  97. package/dist/repo/shared_libs/plays/static-pipeline.ts +560 -0
  98. package/dist/repo/shared_libs/temporal/constants.ts +39 -0
  99. package/dist/repo/shared_libs/temporal/preview-config.ts +153 -0
  100. package/package.json +14 -12
@@ -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
+ }