create-windy 0.3.4 → 0.3.5

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 (101) hide show
  1. package/README.md +20 -3
  2. package/dist/cli.js +582 -109
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +4 -0
  6. package/template/README.md +18 -11
  7. package/template/apps/server/src/application-services.ts +16 -0
  8. package/template/apps/server/src/data-governance/export/authorization-port.test.ts +5 -1
  9. package/template/apps/server/src/data-governance/export/drizzle-repository.integration.test.ts +2 -0
  10. package/template/apps/server/src/data-governance/export/drizzle-repository.ts +1 -0
  11. package/template/apps/server/src/data-governance/export/job-runtime.test.ts +3 -1
  12. package/template/apps/server/src/data-governance/export/job-runtime.ts +53 -14
  13. package/template/apps/server/src/data-governance/export/ndjson-stream.ts +8 -0
  14. package/template/apps/server/src/data-governance/export/postgres-recovery.integration.test.ts +2 -0
  15. package/template/apps/server/src/data-governance/export/renderer-job-runtime.test.ts +235 -0
  16. package/template/apps/server/src/data-governance/export/renderer-runtime.ts +293 -0
  17. package/template/apps/server/src/data-governance/export/renderer-selection.test.ts +67 -0
  18. package/template/apps/server/src/data-governance/export/repository.test.ts +2 -0
  19. package/template/apps/server/src/data-governance/export/request-fingerprint.test.ts +13 -0
  20. package/template/apps/server/src/data-governance/export/request-fingerprint.ts +2 -0
  21. package/template/apps/server/src/data-governance/export/routes.ts +11 -1
  22. package/template/apps/server/src/data-governance/export/service-support.ts +61 -4
  23. package/template/apps/server/src/data-governance/export/service.ts +8 -2
  24. package/template/apps/server/src/data-governance/export/types.ts +27 -0
  25. package/template/apps/server/src/index.ts +1 -0
  26. package/template/apps/server/src/jobs/artifact-service.test.ts +14 -2
  27. package/template/apps/server/src/jobs/artifact-service.ts +10 -0
  28. package/template/apps/server/src/jobs/drizzle-execution-repository.ts +17 -18
  29. package/template/apps/server/src/jobs/drizzle-execution.integration.test.ts +19 -1
  30. package/template/apps/server/src/jobs/drizzle-mappers.ts +3 -1
  31. package/template/apps/server/src/jobs/drizzle-recovery-repository.ts +49 -2
  32. package/template/apps/server/src/jobs/execution-repository.ts +17 -0
  33. package/template/apps/server/src/jobs/in-memory-control.ts +135 -0
  34. package/template/apps/server/src/jobs/in-memory-execution.ts +34 -65
  35. package/template/apps/server/src/jobs/worker.ts +27 -3
  36. package/template/apps/server/src/module-host/governed-export-route-support.ts +24 -5
  37. package/template/apps/server/src/module-host/governed-export-routes.test.ts +5 -1
  38. package/template/apps/server/src/module-host/governed-export-routes.ts +1 -2
  39. package/template/apps/server/src/module-host/governed-renderer-registration.test.ts +247 -0
  40. package/template/apps/server/src/module-host/governed-resources.test.ts +1 -0
  41. package/template/apps/server/src/module-host/governed-resources.ts +56 -0
  42. package/template/apps/server/src/module-host/http-runtime.ts +3 -0
  43. package/template/apps/server/src/module-host/job-port.test.ts +262 -0
  44. package/template/apps/server/src/module-host/job-port.ts +334 -0
  45. package/template/apps/server/src/module-host/request-context.ts +14 -26
  46. package/template/apps/server/src/module-host/route-installer.ts +11 -0
  47. package/template/apps/web/src/app/business-routes.ts +22 -0
  48. package/template/apps/web/src/components/common/AccessSnapshotBoundary.vue +112 -0
  49. package/template/apps/web/src/components/common/AccessSnapshotBoundary.webtest.ts +130 -0
  50. package/template/apps/web/src/components/common/AccessSnapshotNotice.vue +117 -0
  51. package/template/apps/web/src/composables/access-snapshot-change.ts +131 -0
  52. package/template/apps/web/src/composables/access-snapshot-change.webtest.ts +103 -0
  53. package/template/apps/web/src/composables/useAccessSnapshot.ts +48 -2
  54. package/template/apps/web/src/composables/useAccessSnapshot.webtest.ts +54 -0
  55. package/template/apps/web/src/composables/useAccessSnapshotMonitor.ts +42 -0
  56. package/template/apps/web/src/layout/AppShell.vue +28 -25
  57. package/template/apps/web/src/layout/AppShell.webtest.ts +1 -0
  58. package/template/apps/web/src/router/business-routes.ts +68 -0
  59. package/template/apps/web/src/router/business-routes.webtest.ts +85 -0
  60. package/template/apps/web/src/router/index.ts +7 -0
  61. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
  62. package/template/apps/web/src/services/access-api.ts +5 -1
  63. package/template/apps/web/src/services/access-api.webtest.ts +35 -0
  64. package/template/docs/architecture/ai-egress.md +12 -9
  65. package/template/docs/architecture/durable-jobs.md +7 -1
  66. package/template/docs/architecture/foundation-infrastructure.md +163 -0
  67. package/template/docs/architecture/module-host.md +115 -0
  68. package/template/docs/architecture/server-module-interface.md +39 -0
  69. package/template/docs/architecture/web-access-snapshot-resilience.md +60 -0
  70. package/template/docs/development/business-web-routes.md +66 -0
  71. package/template/docs/development/custom-login-page.md +3 -3
  72. package/template/docs/development/module-lifecycle.md +71 -0
  73. package/template/docs/operations/observability.md +5 -1
  74. package/template/docs/platform/agent-runtime.md +6 -6
  75. package/template/docs/platform/agent-tool-sandbox.md +58 -0
  76. package/template/docs/platform/document-ai-runtime.md +204 -0
  77. package/template/docs/platform/governed-export-renderers.md +132 -0
  78. package/template/docs/platform/navigation-registry.md +5 -0
  79. package/template/docs/platform/organization-membership.md +2 -1
  80. package/template/docs/platform/speech-ai-runtime.md +156 -0
  81. package/template/docs/platform/system-crud-api.md +7 -1
  82. package/template/docs/platform/vision-ai-runtime.md +97 -0
  83. package/template/docs/platform/web-system-crud.md +6 -0
  84. package/template/packages/config/package.json +1 -1
  85. package/template/packages/database/drizzle/0042_puzzling_rumiko_fujikawa.sql +27 -0
  86. package/template/packages/database/drizzle/meta/0042_snapshot.json +8541 -0
  87. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  88. package/template/packages/database/package.json +1 -1
  89. package/template/packages/database/src/schema/durable-jobs.ts +1 -0
  90. package/template/packages/database/src/schema/governed-exports.ts +15 -0
  91. package/template/packages/database/src/schema-governed-exports.test.ts +21 -1
  92. package/template/packages/jobs/package.json +1 -1
  93. package/template/packages/jobs/src/handlers.ts +1 -0
  94. package/template/packages/jobs/src/types.ts +1 -0
  95. package/template/packages/modules/package.json +1 -1
  96. package/template/packages/server-sdk/index.ts +11 -0
  97. package/template/packages/server-sdk/package.json +1 -1
  98. package/template/packages/server-sdk/src/data-governance-registration.ts +46 -0
  99. package/template/packages/server-sdk/src/jobs-port.ts +69 -0
  100. package/template/packages/server-sdk/src/request-context.ts +2 -2
  101. package/template/packages/shared/package.json +1 -1
@@ -0,0 +1,293 @@
1
+ import type {
2
+ GovernedExportRenderedArtifact,
3
+ GovernedExportWatermarkContext,
4
+ ModuleGovernedExportRendererRegistration,
5
+ } from "@southwind-ai/server-sdk";
6
+ import type { JsonValue } from "@southwind-ai/shared";
7
+ import type { GovernedExportSource } from "./source.js";
8
+ import { createWatermarkedNdjsonStream } from "./ndjson-stream.js";
9
+ import type {
10
+ GovernedExportRendererSnapshot,
11
+ GovernedExportRequest,
12
+ } from "./types.js";
13
+
14
+ export interface GovernedExportRendererBinding {
15
+ readonly resourceType: string;
16
+ readonly renderer: ModuleGovernedExportRendererRegistration;
17
+ }
18
+
19
+ interface RenderContext {
20
+ readonly request: GovernedExportRequest;
21
+ readonly source: GovernedExportSource;
22
+ readonly jobId: string;
23
+ readonly generatedAt: string;
24
+ readonly signal: AbortSignal;
25
+ readonly heartbeat: () => Promise<void>;
26
+ readonly beforePage: () => Promise<void>;
27
+ readonly pageCompleted: (completed: number) => Promise<void>;
28
+ }
29
+
30
+ export interface GovernedExportRenderSession {
31
+ readonly body: ReadableStream<Uint8Array>;
32
+ readonly contentMetadata?: Readonly<Record<string, JsonValue>>;
33
+ assertRecordsConsumed(): void;
34
+ dispose(): void;
35
+ }
36
+
37
+ export async function createGovernedExportRenderSession(
38
+ input: RenderContext,
39
+ bindings: readonly GovernedExportRendererBinding[],
40
+ ): Promise<GovernedExportRenderSession> {
41
+ const watermark = watermarkOf(input);
42
+ const binding = bindings.find(
43
+ ({ resourceType, renderer }) =>
44
+ resourceType === input.request.resourceType &&
45
+ renderer.formatKey === input.request.renderer.formatKey,
46
+ );
47
+ if (!binding) {
48
+ if (!isLegacyNdjson(input.request.renderer)) {
49
+ throw new Error("EXPORT_RENDERER_NOT_REGISTERED");
50
+ }
51
+ const body = createWatermarkedNdjsonStream({
52
+ ...input,
53
+ watermark,
54
+ maxRecords: input.request.renderer.maxRecords,
55
+ });
56
+ return {
57
+ body: limitOutput(
58
+ body,
59
+ input.request.renderer.maxOutputBytes,
60
+ input.signal,
61
+ ),
62
+ assertRecordsConsumed() {},
63
+ dispose() {},
64
+ };
65
+ }
66
+ assertRendererSnapshot(binding.renderer, input.request.renderer);
67
+ const abort = linkedAbort(
68
+ input.signal,
69
+ binding.renderer.limits.timeoutSeconds,
70
+ );
71
+ const records = governedRecords(
72
+ input,
73
+ binding.renderer.limits.maxRecords,
74
+ abort.signal,
75
+ );
76
+ let artifact: GovernedExportRenderedArtifact;
77
+ let body: ReadableStream<Uint8Array>;
78
+ try {
79
+ artifact = await raceAbort(
80
+ Promise.resolve(
81
+ binding.renderer.render(
82
+ { records: records.iterable, watermark },
83
+ { signal: abort.signal, heartbeat: input.heartbeat },
84
+ ),
85
+ ),
86
+ abort.signal,
87
+ );
88
+ validateContentMetadata(artifact.contentMetadata);
89
+ body = limitOutput(
90
+ artifact.body,
91
+ binding.renderer.limits.maxOutputBytes,
92
+ abort.signal,
93
+ );
94
+ } catch (error) {
95
+ abort.dispose();
96
+ throw error;
97
+ }
98
+ return {
99
+ body,
100
+ contentMetadata: artifact.contentMetadata,
101
+ assertRecordsConsumed() {
102
+ if (!records.consumed()) throw new Error("EXPORT_RECORDS_NOT_CONSUMED");
103
+ },
104
+ dispose: abort.dispose,
105
+ };
106
+ }
107
+
108
+ function governedRecords(
109
+ input: RenderContext,
110
+ maxRecords: number,
111
+ signal: AbortSignal,
112
+ ) {
113
+ let completed = 0;
114
+ let finished = false;
115
+ async function* stream() {
116
+ const pageStream = input.source.pages(input.request, signal);
117
+ const pages = pageStream[Symbol.asyncIterator]();
118
+ try {
119
+ while (true) {
120
+ throwIfAborted(signal);
121
+ await input.beforePage();
122
+ const page = await raceAbort(pages.next(), signal);
123
+ if (page.done) {
124
+ finished = true;
125
+ return;
126
+ }
127
+ for (const record of page.value) {
128
+ completed += 1;
129
+ if (completed > maxRecords) {
130
+ throw new Error("EXPORT_RECORD_LIMIT_EXCEEDED");
131
+ }
132
+ yield record as Readonly<Record<string, JsonValue>>;
133
+ }
134
+ await input.pageCompleted(completed);
135
+ }
136
+ } finally {
137
+ await pages.return?.();
138
+ }
139
+ }
140
+ return { iterable: stream(), consumed: () => finished };
141
+ }
142
+
143
+ function limitOutput(
144
+ body: ReadableStream<Uint8Array>,
145
+ maxBytes: number,
146
+ signal: AbortSignal,
147
+ ) {
148
+ const reader = body.getReader();
149
+ let bytes = 0;
150
+ return new ReadableStream<Uint8Array>({
151
+ async pull(controller) {
152
+ try {
153
+ const result = await raceAbort(reader.read(), signal);
154
+ if (result.done) return controller.close();
155
+ bytes += result.value.byteLength;
156
+ if (bytes > maxBytes) throw new Error("EXPORT_OUTPUT_LIMIT_EXCEEDED");
157
+ controller.enqueue(result.value);
158
+ } catch (error) {
159
+ await reader.cancel(error).catch(() => undefined);
160
+ controller.error(error);
161
+ }
162
+ },
163
+ cancel(reason) {
164
+ return reader.cancel(reason);
165
+ },
166
+ });
167
+ }
168
+
169
+ function linkedAbort(parent: AbortSignal, timeoutSeconds: number) {
170
+ const controller = new AbortController();
171
+ const onAbort = () => controller.abort(parent.reason ?? "cancelled");
172
+ parent.addEventListener("abort", onAbort, { once: true });
173
+ if (parent.aborted) onAbort();
174
+ const timeout = setTimeout(
175
+ () => controller.abort("renderer-timeout"),
176
+ timeoutSeconds * 1_000,
177
+ );
178
+ return {
179
+ signal: controller.signal,
180
+ dispose() {
181
+ clearTimeout(timeout);
182
+ parent.removeEventListener("abort", onAbort);
183
+ },
184
+ };
185
+ }
186
+
187
+ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
188
+ throwIfAborted(signal);
189
+ return new Promise<T>((resolve, reject) => {
190
+ const aborted = () => reject(new Error("EXPORT_RENDERER_ABORTED"));
191
+ signal.addEventListener("abort", aborted, { once: true });
192
+ promise.then(
193
+ (value) => {
194
+ signal.removeEventListener("abort", aborted);
195
+ resolve(value);
196
+ },
197
+ (error) => {
198
+ signal.removeEventListener("abort", aborted);
199
+ reject(error);
200
+ },
201
+ );
202
+ });
203
+ }
204
+
205
+ function throwIfAborted(signal: AbortSignal) {
206
+ if (signal.aborted) throw new Error("EXPORT_RENDERER_ABORTED");
207
+ }
208
+
209
+ function assertRendererSnapshot(
210
+ renderer: ModuleGovernedExportRendererRegistration,
211
+ snapshot: GovernedExportRendererSnapshot,
212
+ ) {
213
+ if (
214
+ snapshot.formatKey !== renderer.formatKey ||
215
+ snapshot.templateKey !== renderer.templateKey ||
216
+ snapshot.templateVersion !== renderer.templateVersion ||
217
+ snapshot.inputSchemaKey !== renderer.inputSchemaKey ||
218
+ snapshot.contentType !== renderer.contentType ||
219
+ snapshot.filenameExtension !== renderer.filenameExtension ||
220
+ snapshot.maxRecords !== renderer.limits.maxRecords ||
221
+ snapshot.maxOutputBytes !== renderer.limits.maxOutputBytes ||
222
+ snapshot.timeoutSeconds !== renderer.limits.timeoutSeconds
223
+ ) {
224
+ throw new Error("EXPORT_RENDERER_VERSION_DRIFT");
225
+ }
226
+ }
227
+
228
+ function isLegacyNdjson(snapshot: GovernedExportRendererSnapshot) {
229
+ return (
230
+ snapshot.formatKey === "ndjson" &&
231
+ snapshot.templateKey === "system.governed-export.ndjson" &&
232
+ snapshot.templateVersion === "1" &&
233
+ snapshot.inputSchemaKey === "system.governed-export.record.v1" &&
234
+ snapshot.contentType === "application/x-ndjson" &&
235
+ snapshot.filenameExtension === "ndjson" &&
236
+ snapshot.maxRecords === 50_000 &&
237
+ snapshot.maxOutputBytes === 10 * 1024 * 1024 &&
238
+ snapshot.timeoutSeconds === 900
239
+ );
240
+ }
241
+
242
+ function validateContentMetadata(
243
+ metadata: Readonly<Record<string, JsonValue>> | undefined,
244
+ ) {
245
+ if (
246
+ metadata &&
247
+ (!isJsonObject(metadata) || JSON.stringify(metadata).length > 16_000)
248
+ ) {
249
+ throw new Error("EXPORT_CONTENT_METADATA_TOO_LARGE");
250
+ }
251
+ }
252
+
253
+ function isJsonObject(value: unknown, depth = 0): boolean {
254
+ if (
255
+ depth > 8 ||
256
+ !value ||
257
+ Array.isArray(value) ||
258
+ typeof value !== "object"
259
+ ) {
260
+ return false;
261
+ }
262
+ return Object.entries(value).every(
263
+ ([key, item]) => key.length <= 160 && isJson(item, depth + 1),
264
+ );
265
+ }
266
+
267
+ function isJson(value: unknown, depth: number): boolean {
268
+ if (depth > 8) return false;
269
+ if (
270
+ value === null ||
271
+ typeof value === "string" ||
272
+ typeof value === "boolean"
273
+ ) {
274
+ return true;
275
+ }
276
+ if (typeof value === "number") return Number.isFinite(value);
277
+ if (Array.isArray(value)) {
278
+ return (
279
+ value.length <= 1_000 && value.every((item) => isJson(item, depth + 1))
280
+ );
281
+ }
282
+ return isJsonObject(value, depth);
283
+ }
284
+
285
+ function watermarkOf(input: RenderContext): GovernedExportWatermarkContext {
286
+ return {
287
+ actorId: input.request.requesterId,
288
+ generatedAt: input.generatedAt,
289
+ scopeHash: input.request.scopeHash,
290
+ jobId: input.jobId,
291
+ requestId: input.request.id,
292
+ };
293
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ GovernedExportServiceError,
4
+ legacyNdjsonRenderer,
5
+ selectRenderer,
6
+ type CreateGovernedExportInput,
7
+ type GovernedExportTarget,
8
+ } from "./service-support.js";
9
+
10
+ const input: CreateGovernedExportInput = {
11
+ idempotencyKey: "request-1",
12
+ reason: "生成正式治理报告",
13
+ query: {},
14
+ };
15
+ const target: GovernedExportTarget = {
16
+ resourceType: "reports.official",
17
+ policyKey: "reports.official.fields",
18
+ policyVersion: "1",
19
+ fieldKeys: ["content"],
20
+ };
21
+
22
+ describe("governed export renderer selection", () => {
23
+ test("未声明 Renderer 的资源保持 NDJSON 兼容", () => {
24
+ expect(selectRenderer(input, target)).toEqual(legacyNdjsonRenderer);
25
+ expect(
26
+ selectRenderer({ ...input, formatKey: "ndjson" }, target),
27
+ ).toEqual(legacyNdjsonRenderer);
28
+ });
29
+
30
+ test("声明 Renderer 后必须显式选择已注册格式", () => {
31
+ const configured = {
32
+ ...target,
33
+ renderers: [
34
+ {
35
+ formatKey: "pdf",
36
+ templateKey: "reports.official.pdf",
37
+ templateVersion: "1",
38
+ inputSchemaKey: "reports.official.input.v1",
39
+ contentType: "application/pdf",
40
+ filenameExtension: "pdf",
41
+ maxRecords: 100,
42
+ maxOutputBytes: 1_000_000,
43
+ timeoutSeconds: 60,
44
+ },
45
+ ],
46
+ };
47
+ expectSelectionError(configured, undefined, "EXPORT_FORMAT_REQUIRED");
48
+ expectSelectionError(configured, "docx", "EXPORT_FORMAT_NOT_REGISTERED");
49
+ expect(selectRenderer({ ...input, formatKey: "pdf" }, configured)).toEqual(
50
+ configured.renderers[0],
51
+ );
52
+ });
53
+ });
54
+
55
+ function expectSelectionError(
56
+ configured: GovernedExportTarget,
57
+ formatKey: string | undefined,
58
+ code: string,
59
+ ) {
60
+ try {
61
+ selectRenderer({ ...input, formatKey }, configured);
62
+ throw new Error("应拒绝 Renderer 选择");
63
+ } catch (error) {
64
+ expect(error).toBeInstanceOf(GovernedExportServiceError);
65
+ expect((error as GovernedExportServiceError).code).toBe(code);
66
+ }
67
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { InMemoryGovernedExportRepository } from "./repository.js";
3
+ import { legacyGovernedExportRendererSnapshot } from "./types.js";
3
4
 
4
5
  const now = new Date("2026-07-16T01:00:00.000Z");
5
6
 
@@ -25,6 +26,7 @@ describe("governed export repository", () => {
25
26
  },
26
27
  scopeHash: "a".repeat(64),
27
28
  querySnapshot: { includeDeleted: false },
29
+ renderer: legacyGovernedExportRendererSnapshot,
28
30
  approvalRequired: true,
29
31
  expiresAt: "2026-07-17T01:00:00.000Z",
30
32
  } satisfies Parameters<typeof repository.create>[0];
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { fingerprintGovernedExportRequest } from "./request-fingerprint.js";
3
+ import { legacyGovernedExportRendererSnapshot } from "./types.js";
3
4
 
4
5
  const request = {
5
6
  resourceType: "system.user",
@@ -10,6 +11,7 @@ const request = {
10
11
  reason: "合规复核用户字段",
11
12
  scopeHash: "a".repeat(64),
12
13
  querySnapshot: { includeDeleted: false, q: "审计" },
14
+ renderer: legacyGovernedExportRendererSnapshot,
13
15
  approvalRequired: true,
14
16
  };
15
17
 
@@ -29,6 +31,17 @@ describe("governed export request fingerprint", () => {
29
31
  reason: "另一项合规复核用途",
30
32
  }),
31
33
  ).not.toBe(first);
34
+ expect(
35
+ fingerprintGovernedExportRequest({
36
+ ...request,
37
+ renderer: {
38
+ ...legacyGovernedExportRendererSnapshot,
39
+ formatKey: "pdf",
40
+ contentType: "application/pdf",
41
+ filenameExtension: "pdf",
42
+ },
43
+ }),
44
+ ).not.toBe(first);
32
45
  });
33
46
 
34
47
  test("数据库标识和到期时间不参与同一业务请求的摘要", () => {
@@ -11,6 +11,7 @@ type FingerprintedRequest = Pick<
11
11
  | "reason"
12
12
  | "scopeHash"
13
13
  | "querySnapshot"
14
+ | "renderer"
14
15
  | "approvalRequired"
15
16
  >;
16
17
 
@@ -26,6 +27,7 @@ export function fingerprintGovernedExportRequest(
26
27
  reason: request.reason,
27
28
  scopeHash: request.scopeHash,
28
29
  querySnapshot: request.querySnapshot,
30
+ renderer: request.renderer,
29
31
  approvalRequired: request.approvalRequired,
30
32
  };
31
33
  return createHash("sha256").update(stableJson(payload)).digest("hex");
@@ -112,7 +112,12 @@ async function guarded<T>(
112
112
  }
113
113
 
114
114
  function parseCreate(body: unknown) {
115
- const value = strictObject(body, ["idempotencyKey", "reason", "query"]);
115
+ const value = strictObject(body, [
116
+ "idempotencyKey",
117
+ "reason",
118
+ "query",
119
+ "formatKey",
120
+ ]);
116
121
  const query = strictObject(value.query || {}, [
117
122
  "q",
118
123
  "status",
@@ -126,9 +131,11 @@ function parseCreate(body: unknown) {
126
131
  ) {
127
132
  throw new GovernedExportServiceError("EXPORT_QUERY_INVALID", 400);
128
133
  }
134
+ const formatKey = optionalString(value.formatKey);
129
135
  return {
130
136
  idempotencyKey: requiredString(value.idempotencyKey),
131
137
  reason: requiredString(value.reason),
138
+ ...(formatKey === undefined ? {} : { formatKey }),
132
139
  query: {
133
140
  q: optionalString(query.q),
134
141
  status: status as LifecycleStatus | undefined,
@@ -200,6 +207,9 @@ function project(request: GovernedExportRequest) {
200
207
  expiresAt: request.expiresAt,
201
208
  createdAt: request.createdAt,
202
209
  updatedAt: request.updatedAt,
210
+ formatKey: request.renderer.formatKey,
211
+ templateKey: request.renderer.templateKey,
212
+ templateVersion: request.renderer.templateVersion,
203
213
  };
204
214
  }
205
215
 
@@ -2,12 +2,17 @@ import type { GovernedExportQuerySnapshot } from "@southwind-ai/database";
2
2
  import type { AuditEvent } from "@southwind-ai/shared";
3
3
  import { auditActor, type RequestGuardContext } from "../../guards.js";
4
4
  import type { DurableArtifactService } from "../../jobs/artifact-service.js";
5
- import type { GovernedExportRequest, GovernedExportStatus } from "./types.js";
5
+ import {
6
+ legacyGovernedExportRendererSnapshot,
7
+ type GovernedExportRequest,
8
+ type GovernedExportStatus,
9
+ } from "./types.js";
6
10
 
7
11
  export interface CreateGovernedExportInput {
8
12
  idempotencyKey: string;
9
13
  reason: string;
10
14
  query: GovernedExportQuerySnapshot;
15
+ formatKey?: string;
11
16
  }
12
17
 
13
18
  export interface GovernedExportPolicy {
@@ -22,8 +27,25 @@ export interface GovernedExportTarget {
22
27
  readonly fieldKeys: readonly string[];
23
28
  readonly approvalRequired?: boolean;
24
29
  readonly retentionSeconds?: number;
30
+ readonly renderers?: readonly GovernedExportRendererTarget[];
25
31
  }
26
32
 
33
+ export interface GovernedExportRendererTarget {
34
+ readonly formatKey: string;
35
+ readonly templateKey: string;
36
+ readonly templateVersion: string;
37
+ readonly inputSchemaKey: string;
38
+ readonly contentType: string;
39
+ readonly filenameExtension: string;
40
+ readonly maxRecords: number;
41
+ readonly maxOutputBytes: number;
42
+ readonly timeoutSeconds: number;
43
+ }
44
+
45
+ export const legacyNdjsonRenderer: GovernedExportRendererTarget = {
46
+ ...legacyGovernedExportRendererSnapshot,
47
+ };
48
+
27
49
  export const governedUserTarget: GovernedExportTarget = {
28
50
  resourceType: "system.user",
29
51
  policyKey: "system.user.fields",
@@ -50,6 +72,29 @@ export function validateCreate(input: CreateGovernedExportInput) {
50
72
  }
51
73
  }
52
74
 
75
+ export function selectRenderer(
76
+ input: CreateGovernedExportInput,
77
+ target: GovernedExportTarget,
78
+ ): GovernedExportRendererTarget {
79
+ const renderers = target.renderers;
80
+ if (!renderers?.length) {
81
+ if (input.formatKey !== undefined && input.formatKey !== "ndjson") {
82
+ throw new GovernedExportServiceError("EXPORT_FORMAT_NOT_REGISTERED", 400);
83
+ }
84
+ return legacyNdjsonRenderer;
85
+ }
86
+ if (!input.formatKey?.trim()) {
87
+ throw new GovernedExportServiceError("EXPORT_FORMAT_REQUIRED", 400);
88
+ }
89
+ const renderer = renderers.find(
90
+ ({ formatKey }) => formatKey === input.formatKey,
91
+ );
92
+ if (!renderer) {
93
+ throw new GovernedExportServiceError("EXPORT_FORMAT_NOT_REGISTERED", 400);
94
+ }
95
+ return renderer;
96
+ }
97
+
53
98
  export function validateTarget(target: GovernedExportTarget) {
54
99
  if (
55
100
  !target.resourceType.trim() ||
@@ -83,8 +128,14 @@ export function assertArtifactMetadata(
83
128
  if (
84
129
  artifact.jobId !== request.durableJobId ||
85
130
  artifact.ownerId !== request.requesterId ||
86
- artifact.contentType !== "application/x-ndjson" ||
131
+ artifact.contentType !== request.renderer.contentType ||
132
+ !artifact.filename.endsWith(`.${request.renderer.filenameExtension}`) ||
133
+ artifact.metadata?.formatKey !== request.renderer.formatKey ||
134
+ artifact.metadata?.templateKey !== request.renderer.templateKey ||
135
+ artifact.metadata?.templateVersion !== request.renderer.templateVersion ||
136
+ artifact.metadata?.inputSchemaKey !== request.renderer.inputSchemaKey ||
87
137
  artifact.byteSize < 0 ||
138
+ artifact.byteSize > request.renderer.maxOutputBytes ||
88
139
  !/^[a-f0-9]{64}$/.test(artifact.sha256)
89
140
  ) {
90
141
  throw new GovernedExportServiceError("EXPORT_ARTIFACT_INVALID", 409);
@@ -113,7 +164,7 @@ export function exportEvent(
113
164
  export function downloadEvent(
114
165
  context: RequestGuardContext,
115
166
  request: GovernedExportRequest,
116
- sha256: string,
167
+ artifact: Awaited<ReturnType<DurableArtifactService["inspect"]>>,
117
168
  phase: "intent" | "authorized",
118
169
  ) {
119
170
  return exportEvent(
@@ -124,7 +175,13 @@ export function downloadEvent(
124
175
  phase,
125
176
  scopeHash: request.scopeHash,
126
177
  jobId: request.durableJobId,
127
- sha256,
178
+ sha256: artifact.sha256,
179
+ byteSize: artifact.byteSize,
180
+ filename: artifact.filename,
181
+ contentType: artifact.contentType,
182
+ formatKey: request.renderer.formatKey,
183
+ templateKey: request.renderer.templateKey,
184
+ templateVersion: request.renderer.templateVersion,
128
185
  },
129
186
  request.resourceType,
130
187
  );
@@ -19,6 +19,7 @@ import {
19
19
  exportEvent,
20
20
  GovernedExportServiceError,
21
21
  governedUserTarget,
22
+ selectRenderer,
22
23
  validateCreate,
23
24
  validateTarget,
24
25
  type CreateGovernedExportInput,
@@ -54,6 +55,7 @@ export class GovernedExportService {
54
55
  ) {
55
56
  validateCreate(input);
56
57
  validateTarget(target);
58
+ const renderer = selectRenderer(input, target);
57
59
  const scope = captureGovernedExportScope(context.actor.scopeContext);
58
60
  const id = Bun.randomUUIDv7();
59
61
  const retentionSeconds = Math.min(
@@ -76,6 +78,7 @@ export class GovernedExportService {
76
78
  scopeSnapshot: scope.snapshot,
77
79
  scopeHash: scope.hash,
78
80
  querySnapshot: structuredClone(input.query),
81
+ renderer: structuredClone(renderer),
79
82
  approvalRequired: target.approvalRequired ?? this.policy.approvalRequired,
80
83
  expiresAt,
81
84
  };
@@ -99,6 +102,9 @@ export class GovernedExportService {
99
102
  policyVersion: created.request.policyVersion,
100
103
  scopeHash: created.request.scopeHash,
101
104
  requestFingerprint: created.request.requestFingerprint,
105
+ formatKey: created.request.renderer.formatKey,
106
+ templateKey: created.request.renderer.templateKey,
107
+ templateVersion: created.request.renderer.templateVersion,
102
108
  replayed: !created.created,
103
109
  },
104
110
  created.request.resourceType,
@@ -173,12 +179,12 @@ export class GovernedExportService {
173
179
  );
174
180
  assertArtifactMetadata(artifact, request);
175
181
  await this.audit.recordCriticalEvent(
176
- downloadEvent(context, request, artifact.sha256, "intent"),
182
+ downloadEvent(context, request, artifact, "intent"),
177
183
  );
178
184
  const opened = await this.artifacts.openInspected(artifact);
179
185
  try {
180
186
  await this.audit.recordCriticalEvent(
181
- downloadEvent(context, request, artifact.sha256, "authorized"),
187
+ downloadEvent(context, request, artifact, "authorized"),
182
188
  );
183
189
  } catch (error) {
184
190
  await opened.body.cancel().catch(() => undefined);
@@ -26,6 +26,7 @@ export interface GovernedExportRequest {
26
26
  scopeSnapshot: GovernedExportScopeSnapshot;
27
27
  scopeHash: string;
28
28
  querySnapshot: GovernedExportQuerySnapshot;
29
+ renderer: GovernedExportRendererSnapshot;
29
30
  status: GovernedExportStatus;
30
31
  approvalRequired: boolean;
31
32
  approvalId?: string;
@@ -49,7 +50,33 @@ export interface CreateGovernedExportRequest {
49
50
  scopeSnapshot: GovernedExportScopeSnapshot;
50
51
  scopeHash: string;
51
52
  querySnapshot: GovernedExportQuerySnapshot;
53
+ renderer: GovernedExportRendererSnapshot;
52
54
  approvalRequired: boolean;
53
55
  approvalId?: string;
54
56
  expiresAt: string;
55
57
  }
58
+
59
+ export interface GovernedExportRendererSnapshot {
60
+ formatKey: string;
61
+ templateKey: string;
62
+ templateVersion: string;
63
+ inputSchemaKey: string;
64
+ contentType: string;
65
+ filenameExtension: string;
66
+ maxRecords: number;
67
+ maxOutputBytes: number;
68
+ timeoutSeconds: number;
69
+ }
70
+
71
+ export const legacyGovernedExportRendererSnapshot: GovernedExportRendererSnapshot =
72
+ {
73
+ formatKey: "ndjson",
74
+ templateKey: "system.governed-export.ndjson",
75
+ templateVersion: "1",
76
+ inputSchemaKey: "system.governed-export.record.v1",
77
+ contentType: "application/x-ndjson",
78
+ filenameExtension: "ndjson",
79
+ maxRecords: 50_000,
80
+ maxOutputBytes: 10 * 1024 * 1024,
81
+ timeoutSeconds: 900,
82
+ };
@@ -307,6 +307,7 @@ installModuleHttpRuntime(moduleHosts, app, {
307
307
  features,
308
308
  // @windy-module system.scheduler begin
309
309
  jobs: durableJobRuntime.service,
310
+ jobWorker: durableJobRuntime.worker,
310
311
  // @windy-module system.scheduler end
311
312
  // @windy-module system.storage begin
312
313
  storage: storageRuntime,