@gencow/core 0.1.28 → 0.1.29

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 (65) hide show
  1. package/dist/auth-config.d.ts +92 -5
  2. package/dist/config.d.ts +107 -0
  3. package/dist/config.js +12 -0
  4. package/dist/context.d.ts +139 -0
  5. package/dist/context.js +3 -0
  6. package/dist/crud.d.ts +5 -5
  7. package/dist/crud.js +19 -35
  8. package/dist/document-types.d.ts +2 -2
  9. package/dist/http-action.d.ts +77 -0
  10. package/dist/http-action.js +41 -0
  11. package/dist/index.d.ts +21 -5
  12. package/dist/index.js +12 -3
  13. package/dist/platform-capacity-profile.d.ts +19 -0
  14. package/dist/platform-capacity-profile.js +94 -0
  15. package/dist/procedure.d.ts +58 -0
  16. package/dist/procedure.js +115 -0
  17. package/dist/rag-schema.d.ts +449 -540
  18. package/dist/reactive-mutation-types.d.ts +11 -0
  19. package/dist/reactive-mutation-types.js +1 -0
  20. package/dist/reactive-mutation.d.ts +51 -0
  21. package/dist/reactive-mutation.js +75 -0
  22. package/dist/reactive-query-types.d.ts +12 -0
  23. package/dist/reactive-query-types.js +1 -0
  24. package/dist/reactive-query.d.ts +14 -0
  25. package/dist/reactive-query.js +28 -0
  26. package/dist/reactive-realtime.d.ts +48 -0
  27. package/dist/reactive-realtime.js +236 -0
  28. package/dist/reactive.d.ts +16 -5
  29. package/dist/reactive.js +65 -0
  30. package/dist/runtime-env-policy.js +1 -1
  31. package/dist/server.d.ts +1 -1
  32. package/dist/storage-metering.d.ts +13 -0
  33. package/dist/storage-metering.js +18 -0
  34. package/dist/storage.d.ts +3 -1
  35. package/dist/storage.js +11 -7
  36. package/dist/wake-app-result.d.ts +22 -0
  37. package/dist/wake-app-result.js +11 -0
  38. package/dist/workflow-types.d.ts +13 -1
  39. package/dist/workflow.d.ts +1 -1
  40. package/dist/workflow.js +136 -11
  41. package/dist/workflows-api.js +71 -3
  42. package/package.json +4 -1
  43. package/src/auth-config.ts +104 -3
  44. package/src/config.ts +119 -0
  45. package/src/context.ts +152 -0
  46. package/src/crud.ts +18 -35
  47. package/src/document-types.ts +9 -2
  48. package/src/http-action.ts +101 -0
  49. package/src/index.ts +77 -19
  50. package/src/platform-capacity-profile.ts +114 -0
  51. package/src/procedure.ts +283 -0
  52. package/src/reactive-mutation-types.ts +13 -0
  53. package/src/reactive-mutation.ts +115 -0
  54. package/src/reactive-query-types.ts +14 -0
  55. package/src/reactive-query.ts +48 -0
  56. package/src/reactive-realtime.ts +267 -0
  57. package/src/runtime-env-policy.ts +1 -1
  58. package/src/server.ts +6 -1
  59. package/src/storage-metering.ts +35 -0
  60. package/src/storage.ts +14 -6
  61. package/src/wake-app-result.ts +37 -0
  62. package/src/workflow-types.ts +13 -1
  63. package/src/workflow.ts +166 -12
  64. package/src/workflows-api.ts +82 -3
  65. package/src/reactive.ts +0 -593
@@ -0,0 +1,267 @@
1
+ import type { WSContext } from "hono/ws";
2
+ import type { GencowCtx, RealtimeCtx, RealtimeNotifyEvent } from "./context.js";
3
+ import { queryRegistry } from "./reactive-query.js";
4
+ import type { QueryDef } from "./reactive-query-types.js";
5
+
6
+ // ─── WS / subscription + subscription-key helpers (globalThis) ─
7
+ //
8
+ // globalThis 기반 — 서버 번들(인라인)과 node_modules/@gencow/core 양쪽에서
9
+ // 동일한 레지스트리 인스턴스를 공유. Dual-Module Registry 버그 방지.
10
+ // See: docs/analysis/analysis-dual-module-registry.md
11
+
12
+ declare global {
13
+ var __gencow_subscribers: Map<string, Set<WSContext>>;
14
+ var __gencow_connectedClients: Set<WSContext>;
15
+ }
16
+
17
+ if (!globalThis.__gencow_subscribers) globalThis.__gencow_subscribers = new Map();
18
+ if (!globalThis.__gencow_connectedClients) globalThis.__gencow_connectedClients = new Set();
19
+
20
+ export const subscribers = globalThis.__gencow_subscribers;
21
+
22
+ /**
23
+ * Every WebSocket client that ever establishes a connection is tracked here.
24
+ * This allows broadcasting invalidation events to clients that never subscribed
25
+ * to a specific query (e.g. the admin dashboard's raw WebSocket connection).
26
+ */
27
+ export const connectedClients = globalThis.__gencow_connectedClients;
28
+
29
+ const SUBSCRIPTION_KEY_SEPARATOR = "::";
30
+
31
+ function normalizeForStableJson(value: unknown): unknown {
32
+ if (value === undefined) return undefined;
33
+ if (value === null) return null;
34
+ if (value instanceof Date) return value.toISOString();
35
+ if (Array.isArray(value)) return value.map((item) => normalizeForStableJson(item));
36
+ if (typeof value === "object") {
37
+ const source = value as Record<string, unknown>;
38
+ const sorted: Record<string, unknown> = {};
39
+ for (const key of Object.keys(source).sort()) {
40
+ const normalized = normalizeForStableJson(source[key]);
41
+ if (normalized !== undefined) sorted[key] = normalized;
42
+ }
43
+ return sorted;
44
+ }
45
+ return value;
46
+ }
47
+
48
+ function isEmptyPlainObject(value: unknown): boolean {
49
+ return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0;
50
+ }
51
+
52
+ /** Stable subscription key for WebSocket / invalidate fanout (also used by crud for query keys). */
53
+ export function buildQuerySubscriptionKey(queryKey: string, args?: unknown): string {
54
+ const normalized = normalizeForStableJson(args);
55
+ if (normalized === undefined || isEmptyPlainObject(normalized)) return queryKey;
56
+ return `${queryKey}${SUBSCRIPTION_KEY_SEPARATOR}${JSON.stringify(normalized)}`;
57
+ }
58
+
59
+ export function subscriptionKeyMatchesQueryKey(subscriptionKey: string, queryKey: string): boolean {
60
+ return subscriptionKey === queryKey || subscriptionKey.startsWith(`${queryKey}${SUBSCRIPTION_KEY_SEPARATOR}`);
61
+ }
62
+
63
+ export function subscribe(queryKey: string, ws: WSContext) {
64
+ connectedClients.add(ws);
65
+ if (!subscribers.has(queryKey)) {
66
+ subscribers.set(queryKey, new Set());
67
+ }
68
+ subscribers.get(queryKey)!.add(ws);
69
+ }
70
+
71
+ export function unsubscribe(ws: WSContext) {
72
+ connectedClients.delete(ws);
73
+ for (const clients of subscribers.values()) {
74
+ clients.delete(ws);
75
+ }
76
+ }
77
+
78
+ /** Register a raw WS connection without any query subscription (e.g. admin dashboard) */
79
+ export function registerClient(ws: WSContext) {
80
+ connectedClients.add(ws);
81
+ }
82
+
83
+ export function deregisterClient(ws: WSContext) {
84
+ connectedClients.delete(ws);
85
+ for (const clients of subscribers.values()) {
86
+ clients.delete(ws);
87
+ }
88
+ }
89
+
90
+ function sendInvalidateToLocalSubscribers(queryKeys: string[]): void {
91
+ const targets = new Map<WSContext, Set<string>>();
92
+ for (const queryKey of queryKeys) {
93
+ for (const [subscriptionKey, clients] of subscribers) {
94
+ if (!subscriptionKeyMatchesQueryKey(subscriptionKey, queryKey)) continue;
95
+ for (const ws of clients) {
96
+ if (!targets.has(ws)) targets.set(ws, new Set());
97
+ targets.get(ws)!.add(subscriptionKey);
98
+ }
99
+ }
100
+ }
101
+
102
+ for (const [ws, keys] of targets) {
103
+ try {
104
+ ws.send(JSON.stringify({ type: "invalidate", queries: [...keys] }));
105
+ } catch {
106
+ deregisterClient(ws);
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * mutation 실행 시점에 생성되는 RealtimeCtx.
113
+ * emit(): 데이터를 직접 push (초고빈도 mutation용).
114
+ * refresh(): queryKey를 pending 큐에 추가, mutation 완료 후 서버가 query re-run하여 push.
115
+ *
116
+ * 💡 Batching: 같은 queryKey에 대한 emit이 50ms 내에 여러 번 호출되면
117
+ * 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
118
+ *
119
+ * ⚠️ 매 mutation 호출마다 새로 생성해야 합니다 (debounce timer가 mutation scope에 격리).
120
+ *
121
+ * @param options.httpCallback BaaS 모드: Platform WS Gateway에 HTTP로 emit 전달.
122
+ * 설정되면 WS 직접 push 대신 이 콜백을 호출.
123
+ * 로컬 dev에서는 미설정 → 기존 WS 직접 push 유지.
124
+ * @param options.queryMap query 레지스트리 — refresh()에서 query handler를 찾아 re-run.
125
+ * @param options.buildCtxForRefresh refresh 시 query handler에 전달할 ctx 생성 함수.
126
+ */
127
+ export function buildRealtimeCtx(options?: {
128
+ httpCallback?: (event: RealtimeNotifyEvent) => void;
129
+ queryMap?: Map<string, QueryDef<any, any>>;
130
+ buildCtxForRefresh?: () => GencowCtx;
131
+ }): RealtimeCtx & { _hasEmitted: boolean; _pendingRefresh: string[]; _flushRefresh: () => Promise<void> } {
132
+ const pendingEmits = new Map<string, { data: unknown; timer: ReturnType<typeof setTimeout> }>();
133
+ const _pendingRefresh: string[] = [];
134
+ let _hasEmitted = false;
135
+
136
+ return {
137
+ emit(queryKey: string, data: unknown) {
138
+ _hasEmitted = true;
139
+ // 기존 pending timer가 있으면 취소 (debounce)
140
+ const existing = pendingEmits.get(queryKey);
141
+ if (existing) clearTimeout(existing.timer);
142
+
143
+ const timer = setTimeout(() => {
144
+ pendingEmits.delete(queryKey);
145
+
146
+ // BaaS 모드: Platform WS Gateway에 HTTP callback
147
+ if (options?.httpCallback) {
148
+ options.httpCallback({ type: "emit", queryKey, data });
149
+ return;
150
+ }
151
+
152
+ // 로컬 dev: WS 직접 push (기존 동작)
153
+ const clients = subscribers.get(queryKey);
154
+ if (!clients || clients.size === 0) return;
155
+
156
+ const message = JSON.stringify({
157
+ type: "query:updated",
158
+ query: queryKey,
159
+ data,
160
+ });
161
+ for (const ws of clients) {
162
+ try {
163
+ ws.send(message);
164
+ } catch {
165
+ clients.delete(ws);
166
+ }
167
+ }
168
+ }, 50); // 50ms batch window
169
+
170
+ pendingEmits.set(queryKey, { data, timer });
171
+ },
172
+
173
+ invalidate(queryKey: string | string[]) {
174
+ _hasEmitted = true;
175
+ const queryKeys = Array.isArray(queryKey) ? [...new Set(queryKey)] : [queryKey];
176
+ if (options?.httpCallback) {
177
+ options.httpCallback({ type: "invalidate", queryKeys });
178
+ return;
179
+ }
180
+ sendInvalidateToLocalSubscribers(queryKeys);
181
+ },
182
+
183
+ refresh(queryKey: string) {
184
+ _hasEmitted = true; // 경고 억제
185
+ if (!_pendingRefresh.includes(queryKey)) {
186
+ _pendingRefresh.push(queryKey);
187
+ }
188
+ },
189
+
190
+ get _hasEmitted() {
191
+ return _hasEmitted;
192
+ },
193
+ get _pendingRefresh() {
194
+ return [..._pendingRefresh];
195
+ },
196
+
197
+ async _flushRefresh() {
198
+ if (_pendingRefresh.length === 0) return;
199
+
200
+ // queryMap이 없으면 refresh 동작 불가 (로그 경고)
201
+ const qMap = options?.queryMap ?? queryRegistry;
202
+
203
+ for (const key of _pendingRefresh) {
204
+ const queryDef = qMap.get(key);
205
+ if (!queryDef) {
206
+ console.warn(`[gencow] refresh("${key}"): query not found in registry. Skipping.`);
207
+ continue;
208
+ }
209
+ try {
210
+ // refresh용 ctx 생성 (mutation ctx와 동일한 DB/auth 스코프)
211
+ if (!options?.buildCtxForRefresh) {
212
+ console.warn(
213
+ `[gencow] ⚠️ refresh("${key}"): buildCtxForRefresh not provided. ` +
214
+ `Query handler will receive an empty ctx — ctx.db will be undefined. ` +
215
+ `This is a framework configuration error. ` +
216
+ `💡 Ensure buildRealtimeCtx() receives a buildCtxForRefresh callback.`,
217
+ );
218
+ }
219
+ const refreshCtx = options?.buildCtxForRefresh?.() ?? ({} as GencowCtx);
220
+ const result = await queryDef.handler(refreshCtx, {});
221
+
222
+ // emit과 동일한 경로로 push
223
+ if (options?.httpCallback) {
224
+ options.httpCallback({ type: "emit", queryKey: key, data: result });
225
+ } else {
226
+ const clients = subscribers.get(key);
227
+ if (clients && clients.size > 0) {
228
+ const message = JSON.stringify({
229
+ type: "query:updated",
230
+ query: key,
231
+ data: result,
232
+ });
233
+ for (const ws of clients) {
234
+ try {
235
+ ws.send(message);
236
+ } catch {
237
+ clients.delete(ws);
238
+ }
239
+ }
240
+ }
241
+ }
242
+ } catch (e) {
243
+ console.warn(`[gencow] refresh("${key}") failed:`, e instanceof Error ? e.message : e);
244
+ }
245
+ }
246
+ _pendingRefresh.length = 0;
247
+ },
248
+ };
249
+ }
250
+
251
+ export function handleWsMessage(ws: WSContext, raw: string | ArrayBuffer) {
252
+ try {
253
+ const msg = typeof raw === "string" ? JSON.parse(raw) : JSON.parse(raw.toString());
254
+
255
+ if (msg.type === "subscribe" && msg.query) {
256
+ subscribe(msg.query, ws);
257
+ ws.send(JSON.stringify({ type: "subscribed", query: msg.query }));
258
+ }
259
+
260
+ if (msg.type === "unsubscribe" && msg.query) {
261
+ const clients = subscribers.get(msg.query);
262
+ if (clients) clients.delete(ws);
263
+ }
264
+ } catch {
265
+ // ignore malformed messages
266
+ }
267
+ }
@@ -37,7 +37,7 @@ const RESERVED_TENANT_RUNTIME_ENV_KEYS = new Set([
37
37
  "NODE_PATH",
38
38
  ]);
39
39
 
40
- const RESERVED_TENANT_RUNTIME_ENV_PREFIXES = ["__GENCOW_", "GENCOW_DOCUMENT_", "GENCOW_WARM_"];
40
+ const RESERVED_TENANT_RUNTIME_ENV_PREFIXES = ["__GENCOW_", "GENCOW_DOCUMENT_", "GENCOW_TEMPLATE_", "GENCOW_WARM_"];
41
41
 
42
42
  export function isReservedTenantRuntimeEnvKey(key: string): boolean {
43
43
  const normalized = key.trim();
package/src/server.ts CHANGED
@@ -6,5 +6,10 @@
6
6
  * bundled into user functions which run in Firecracker.
7
7
  */
8
8
  export { createStorage, storageRoutes } from "./storage.js";
9
- export type { StorageImageTierConfig, StoredFile } from "./storage.js";
9
+ export type {
10
+ StorageImageTierConfig,
11
+ StorageImageTransformMetric,
12
+ StorageMeteringOptions,
13
+ StoredFile,
14
+ } from "./storage.js";
10
15
  export { createScheduler, getSchedulerInfo } from "./scheduler.js";
@@ -0,0 +1,35 @@
1
+ import * as fs from "fs/promises";
2
+
3
+ export interface StorageImageTransformMetric {
4
+ transformCount: number;
5
+ sourceBytes: number;
6
+ outputBytes: number;
7
+ format: string;
8
+ autoWebp: boolean;
9
+ }
10
+
11
+ export interface StorageMeteringOptions {
12
+ onImageTransform?: (metric: StorageImageTransformMetric) => void | Promise<void>;
13
+ }
14
+
15
+ export async function recordStorageImageTransform(
16
+ options: StorageMeteringOptions | undefined,
17
+ cachePath: string,
18
+ metric: Omit<StorageImageTransformMetric, "transformCount" | "outputBytes">,
19
+ ): Promise<{ size: number }> {
20
+ const stats = await fs.stat(cachePath);
21
+ if (!options?.onImageTransform) return stats;
22
+
23
+ try {
24
+ await options.onImageTransform({
25
+ ...metric,
26
+ transformCount: 1,
27
+ outputBytes: stats.size,
28
+ });
29
+ } catch (error) {
30
+ const msg = error instanceof Error ? error.message : String(error);
31
+ console.warn(`[storage] image transform metering failed: ${msg.slice(0, 120)}`);
32
+ }
33
+
34
+ return stats;
35
+ }
package/src/storage.ts CHANGED
@@ -9,8 +9,11 @@ import {
9
9
  loadStorageMeta,
10
10
  } from "./storage-shared.js";
11
11
  import type { Storage, StorageFile, StorageOptions, StoredFile } from "./storage-shared.js";
12
+ import { recordStorageImageTransform } from "./storage-metering.js";
13
+ import type { StorageMeteringOptions } from "./storage-metering.js";
12
14
 
13
15
  export type { Storage, StorageFile, StorageOptions, StoredFile } from "./storage-shared.js";
16
+ export type { StorageImageTransformMetric, StorageMeteringOptions } from "./storage-metering.js";
14
17
 
15
18
  // ─── Implementation ─────────────────────────────────────
16
19
 
@@ -60,6 +63,8 @@ async function checkStorageQuota(
60
63
  ): Promise<void> {
61
64
  if (quota <= 0) return; // 무제한
62
65
 
66
+ await ensureFilesTable(rawSql);
67
+
63
68
  const rows = await rawSql(`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM _system_files`);
64
69
  const currentUsage = Number((rows[0] as Record<string, string>)?.total || "0");
65
70
  const projectedUsage = currentUsage + newFileSize;
@@ -465,6 +470,7 @@ export function storageRoutes(
465
470
  rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>,
466
471
  storageDir?: string,
467
472
  tierConfig?: StorageImageTierConfig,
473
+ meteringOptions?: StorageMeteringOptions,
468
474
  ) {
469
475
  const baseTierConfig: Required<StorageImageTierConfig> = {
470
476
  autoWebp: tierConfig?.autoWebp ?? true,
@@ -736,15 +742,17 @@ export function storageRoutes(
736
742
 
737
743
  // 변환 실행 → 캐시에 저장
738
744
  await pipeline.toFile(cachePath);
745
+ const cacheStats = await recordStorageImageTransform(meteringOptions, cachePath, {
746
+ sourceBytes: meta.size,
747
+ format: outputFormat || meta.type.replace(/^image\//, ""),
748
+ autoWebp: isAutoWebp,
749
+ });
739
750
 
740
751
  // WebP/AVIF가 원본보다 큰 경우 → 캐시 삭제 + 원본 서빙
741
752
  // (Static Deploy와 동일 전략 — apps.ts L840-847)
742
- if (isAutoWebp) {
743
- const cacheStats = await fs.stat(cachePath);
744
- if (cacheStats.size >= meta.size) {
745
- await fs.unlink(cachePath).catch(() => {});
746
- return serveOriginal(c, meta);
747
- }
753
+ if (isAutoWebp && cacheStats.size >= meta.size) {
754
+ await fs.unlink(cachePath).catch(() => {});
755
+ return serveOriginal(c, meta);
748
756
  }
749
757
 
750
758
  return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
@@ -0,0 +1,37 @@
1
+ export type WakeAppSuccessStatus = "already_running" | "woke";
2
+ export type WakeAppDeferredStatus = "capacity_rejected" | "queue_timeout";
3
+
4
+ export type WakeAppSuccessResult = {
5
+ ok: true;
6
+ status: WakeAppSuccessStatus;
7
+ port: number;
8
+ };
9
+
10
+ export type WakeAppDeferredResult = {
11
+ ok: false;
12
+ status: WakeAppDeferredStatus;
13
+ retryAfterSec: number;
14
+ };
15
+
16
+ export type WakeAppBootFailedResult = {
17
+ ok: false;
18
+ status: "boot_failed";
19
+ error: string;
20
+ };
21
+
22
+ export type WakeAppResult = WakeAppSuccessResult | WakeAppDeferredResult | WakeAppBootFailedResult;
23
+
24
+ export const DEFAULT_WAKE_RETRY_AFTER_SEC = 30;
25
+
26
+ export function buildWakeAppSuccessResult(status: WakeAppSuccessStatus, port: number): WakeAppSuccessResult {
27
+ return { ok: true, status, port };
28
+ }
29
+
30
+ export function buildWakeAppBootFailedResult(error: unknown): WakeAppBootFailedResult {
31
+ const message = error instanceof Error ? error.message : String(error);
32
+ return { ok: false, status: "boot_failed", error: message };
33
+ }
34
+
35
+ export function isWakeAppDeferredResult(result: WakeAppResult): result is WakeAppDeferredResult {
36
+ return !result.ok && (result.status === "capacity_rejected" || result.status === "queue_timeout");
37
+ }
@@ -1,4 +1,4 @@
1
- import type { GencowCtx } from "./reactive.js";
1
+ import type { GencowCtx } from "./context.js";
2
2
  import type { WorkflowDocumentServicesCtx } from "./document-types.js";
3
3
  import type { InferArgs } from "./v.js";
4
4
 
@@ -29,6 +29,7 @@ export interface WorkflowSummary {
29
29
  derivedStatus: WorkflowDerivedStatus;
30
30
  currentStep: string | null;
31
31
  error: string | null;
32
+ errorCode: string | null;
32
33
  retryCount: number;
33
34
  maxRetries: number;
34
35
  maxDurationMs: number;
@@ -81,6 +82,9 @@ export interface WorkflowResumePayload {
81
82
  export interface WorkflowCtx extends Omit<GencowCtx, "services"> {
82
83
  workflowId: string;
83
84
  workflowName: string;
85
+ runId?: string;
86
+ attempt?: number;
87
+ stepAttempt?: number;
84
88
  services: GencowCtx["services"] & WorkflowDocumentServicesCtx;
85
89
  step<TResult>(name: string, run: () => Promise<TResult>): Promise<TResult>;
86
90
  sleep(duration: WorkflowDuration): Promise<void>;
@@ -95,7 +99,11 @@ export type WorkflowHandler<TArgs, TReturn> = (wf: WorkflowCtx, args: TArgs) =>
95
99
  export interface WorkflowOptions<TSchema = any, TReturn = any> {
96
100
  args?: TSchema;
97
101
  public?: boolean;
102
+ version?: string;
98
103
  maxDuration?: WorkflowDuration;
104
+ maxActiveDuration?: WorkflowDuration;
105
+ lifecycleTimeout?: WorkflowDuration;
106
+ concurrency?: number;
99
107
  retries?: number;
100
108
  handler: WorkflowHandler<InferArgs<TSchema>, TReturn>;
101
109
  }
@@ -104,7 +112,11 @@ export interface WorkflowDef<TSchema = any, TReturn = any> {
104
112
  name: string;
105
113
  argsSchema?: TSchema;
106
114
  isPublic: boolean;
115
+ version?: string;
107
116
  maxDurationMs: number;
117
+ maxActiveDurationMs: number;
118
+ lifecycleTimeoutMs: number | null;
119
+ concurrency: number | null;
108
120
  maxRetries: number;
109
121
  handler: WorkflowHandler<InferArgs<TSchema>, TReturn>;
110
122
  }
package/src/workflow.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { sql } from "drizzle-orm";
2
- import { mutation } from "./reactive.js";
3
- import type { MutationDef } from "./reactive.js";
2
+ import { mutation } from "./reactive-mutation.js";
3
+ import type { MutationDef } from "./reactive-mutation-types.js";
4
4
  import type {
5
5
  WorkflowDef,
6
6
  WorkflowDuration,
@@ -82,9 +82,137 @@ export function parseWorkflowDurationMs(raw: WorkflowDuration, label = "workflow
82
82
  return parseDurationString(raw, label);
83
83
  }
84
84
 
85
- function normalizeMaxDurationMs(maxDuration: WorkflowDuration | undefined): number {
85
+ function normalizeMaxDurationMs(maxDuration: WorkflowDuration | undefined, label = "workflow() maxDuration"): number {
86
86
  if (maxDuration == null) return DEFAULT_WORKFLOW_MAX_DURATION_MS;
87
- return parseWorkflowDurationMs(maxDuration, "workflow() maxDuration");
87
+ return parseWorkflowDurationMs(maxDuration, label);
88
+ }
89
+
90
+ function normalizeOptionalDurationMs(
91
+ duration: WorkflowDuration | undefined,
92
+ label: string,
93
+ ): number | null {
94
+ return duration == null ? null : parseWorkflowDurationMs(duration, label);
95
+ }
96
+
97
+ function normalizeConcurrency(concurrency: number | undefined): number | null {
98
+ if (concurrency == null) return null;
99
+ if (!Number.isFinite(concurrency) || concurrency <= 0) {
100
+ throw new Error(`workflow() concurrency must be a positive finite number, got "${concurrency}"`);
101
+ }
102
+ return Math.floor(concurrency);
103
+ }
104
+
105
+ function isMissingWorkflowV2SchemaError(error: unknown): boolean {
106
+ const code =
107
+ error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : "";
108
+ const cause =
109
+ error && typeof error === "object" && "cause" in error ? (error as { cause?: unknown }).cause : null;
110
+ const causeCode =
111
+ cause && typeof cause === "object" && "code" in cause ? String((cause as { code?: unknown }).code) : "";
112
+ const message = error instanceof Error ? error.message : String(error);
113
+ return (
114
+ code === "42P01" ||
115
+ code === "42703" ||
116
+ causeCode === "42P01" ||
117
+ causeCode === "42703" ||
118
+ message.includes('relation "_gencow_workflow_runs_v2" does not exist') ||
119
+ message.includes("relation _gencow_workflow_runs_v2 does not exist") ||
120
+ message.includes('relation "_gencow_workflow_outbox_v2" does not exist') ||
121
+ message.includes("relation _gencow_workflow_outbox_v2 does not exist") ||
122
+ message.includes('column "max_active_duration_ms"') ||
123
+ message.includes('column "retry_count"') ||
124
+ message.includes('column "user_id"')
125
+ );
126
+ }
127
+
128
+ async function tryInsertWorkflowV2WakeOutbox(
129
+ db: { execute: (query: ReturnType<typeof sql>) => Promise<unknown> },
130
+ workflowId: string,
131
+ ): Promise<void> {
132
+ try {
133
+ await db.execute(sql`
134
+ INSERT INTO _gencow_workflow_outbox_v2 (
135
+ id,
136
+ run_id,
137
+ kind,
138
+ available_at,
139
+ status
140
+ )
141
+ VALUES (
142
+ ${`start:${workflowId}`},
143
+ ${workflowId},
144
+ 'wake_run',
145
+ NOW(),
146
+ 'pending'
147
+ )
148
+ ON CONFLICT (id) DO NOTHING
149
+ `);
150
+ } catch (error) {
151
+ if (!isMissingWorkflowV2SchemaError(error)) throw error;
152
+ }
153
+ }
154
+
155
+ async function tryInsertWorkflowV2Run(options: {
156
+ db: { execute: (query: ReturnType<typeof sql>) => Promise<unknown> };
157
+ workflowId: string;
158
+ workflowName: string;
159
+ workflowVersion?: string | null;
160
+ args: unknown;
161
+ userId: string | null;
162
+ maxActiveDurationMs: number;
163
+ lifecycleTimeoutMs: number | null;
164
+ maxRetries: number;
165
+ }): Promise<boolean> {
166
+ try {
167
+ await options.db.execute(sql`
168
+ INSERT INTO _gencow_workflow_runs_v2 (
169
+ id,
170
+ workflow_name,
171
+ workflow_version,
172
+ args_json,
173
+ user_id,
174
+ max_active_duration_ms,
175
+ lifecycle_deadline_at,
176
+ retry_count,
177
+ max_retries,
178
+ max_attempts
179
+ )
180
+ VALUES (
181
+ ${options.workflowId},
182
+ ${options.workflowName},
183
+ ${options.workflowVersion ?? null},
184
+ ${JSON.stringify(options.args)}::jsonb,
185
+ ${options.userId},
186
+ ${options.maxActiveDurationMs},
187
+ CASE
188
+ WHEN ${options.lifecycleTimeoutMs}::bigint IS NULL THEN NULL
189
+ ELSE NOW() + (${options.lifecycleTimeoutMs}::bigint * INTERVAL '1 millisecond')
190
+ END,
191
+ 0,
192
+ ${options.maxRetries},
193
+ ${options.maxRetries + 1}
194
+ )
195
+ `);
196
+ await tryInsertWorkflowV2WakeOutbox(options.db, options.workflowId);
197
+ return true;
198
+ } catch (error) {
199
+ if (isMissingWorkflowV2SchemaError(error)) return false;
200
+ throw error;
201
+ }
202
+ }
203
+
204
+ async function tryDeleteWorkflowV2Run(
205
+ db: { execute: (query: ReturnType<typeof sql>) => Promise<unknown> },
206
+ workflowId: string,
207
+ ): Promise<void> {
208
+ try {
209
+ await db.execute(sql`
210
+ DELETE FROM _gencow_workflow_runs_v2
211
+ WHERE id = ${workflowId}
212
+ `);
213
+ } catch (error) {
214
+ if (!isMissingWorkflowV2SchemaError(error)) throw error;
215
+ }
88
216
  }
89
217
 
90
218
  export function getWorkflowResumeActionName(name: string): string {
@@ -119,14 +247,25 @@ export function workflow<TSchema = any, TReturn = any>(
119
247
  ): MutationDef<TSchema, WorkflowStartResult> {
120
248
  registerWorkflowsApi();
121
249
 
122
- const maxDurationMs = normalizeMaxDurationMs(options.maxDuration);
250
+ const maxActiveDurationMs = normalizeMaxDurationMs(
251
+ options.maxActiveDuration ?? options.maxDuration,
252
+ options.maxActiveDuration == null ? "workflow() maxDuration" : "workflow() maxActiveDuration",
253
+ );
254
+ const lifecycleTimeoutMs = normalizeOptionalDurationMs(
255
+ options.lifecycleTimeout,
256
+ "workflow() lifecycleTimeout",
257
+ );
123
258
  const maxRetries = clampRetries(options.retries);
124
259
 
125
260
  const def: WorkflowDef<TSchema, TReturn> = {
126
261
  name,
127
262
  argsSchema: options.args,
128
263
  isPublic: options.public === true,
129
- maxDurationMs,
264
+ version: options.version,
265
+ maxDurationMs: maxActiveDurationMs,
266
+ maxActiveDurationMs,
267
+ lifecycleTimeoutMs,
268
+ concurrency: normalizeConcurrency(options.concurrency),
130
269
  maxRetries,
131
270
  handler: options.handler,
132
271
  };
@@ -159,16 +298,28 @@ export function workflow<TSchema = any, TReturn = any>(
159
298
  ${workflowId},
160
299
  ${name},
161
300
  ${JSON.stringify(persistedArgs)}::jsonb,
162
- ${realtimeToken},
163
- 'pending',
164
- 0,
165
- ${maxRetries},
166
- ${maxDurationMs},
167
- ${ownerId}
301
+ ${realtimeToken},
302
+ 'pending',
303
+ 0,
304
+ ${maxRetries},
305
+ ${maxActiveDurationMs},
306
+ ${ownerId}
168
307
  )
169
308
  `);
309
+ let insertedWorkflowV2 = false;
170
310
 
171
311
  try {
312
+ insertedWorkflowV2 = await tryInsertWorkflowV2Run({
313
+ db: ctx.unsafeDb,
314
+ workflowId,
315
+ workflowName: name,
316
+ workflowVersion: options.version ?? null,
317
+ args: persistedArgs,
318
+ userId: ownerId,
319
+ maxActiveDurationMs,
320
+ lifecycleTimeoutMs,
321
+ maxRetries,
322
+ });
172
323
  const scheduledJobId = ctx.scheduler.runAfter(0, resumeAction, { workflowId });
173
324
  return {
174
325
  id: workflowId,
@@ -181,6 +332,9 @@ export function workflow<TSchema = any, TReturn = any>(
181
332
  DELETE FROM _gencow_workflows
182
333
  WHERE id = ${workflowId}
183
334
  `);
335
+ if (insertedWorkflowV2) {
336
+ await tryDeleteWorkflowV2Run(ctx.unsafeDb, workflowId);
337
+ }
184
338
  throw error;
185
339
  }
186
340
  },