@aotter/mantle-cloudflare 0.1.0-alpha.2 → 0.1.0-alpha.4

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 (39) hide show
  1. package/README.md +31 -2
  2. package/dist/auth/conventionalAuth.d.ts +4 -1
  3. package/dist/auth/conventionalAuth.d.ts.map +1 -1
  4. package/dist/auth/conventionalAuth.js +86 -20
  5. package/dist/auth/conventionalAuth.js.map +1 -1
  6. package/dist/bindings/conventionalBindings.d.ts.map +1 -1
  7. package/dist/bindings/conventionalBindings.js +2 -0
  8. package/dist/bindings/conventionalBindings.js.map +1 -1
  9. package/dist/mount/bootRuntimeOnce.d.ts.map +1 -1
  10. package/dist/mount/bootRuntimeOnce.js +1 -0
  11. package/dist/mount/bootRuntimeOnce.js.map +1 -1
  12. package/dist/mount/cmsConfig.d.ts +1 -0
  13. package/dist/mount/cmsConfig.d.ts.map +1 -1
  14. package/dist/mount/index.d.ts +1 -1
  15. package/dist/mount/index.d.ts.map +1 -1
  16. package/dist/mount/index.js.map +1 -1
  17. package/dist/mount/mountMcp.d.ts.map +1 -1
  18. package/dist/mount/mountMcp.js +23 -7
  19. package/dist/mount/mountMcp.js.map +1 -1
  20. package/dist/mount/mountPublicRoutes.d.ts +8 -3
  21. package/dist/mount/mountPublicRoutes.d.ts.map +1 -1
  22. package/dist/mount/mountPublicRoutes.js +209 -28
  23. package/dist/mount/mountPublicRoutes.js.map +1 -1
  24. package/dist/mount/mountServerEndpoints.d.ts.map +1 -1
  25. package/dist/mount/mountServerEndpoints.js +197 -81
  26. package/dist/mount/mountServerEndpoints.js.map +1 -1
  27. package/dist/oauth/cachePolicy.d.ts +1 -0
  28. package/dist/oauth/cachePolicy.d.ts.map +1 -1
  29. package/dist/oauth/cachePolicy.js +2 -0
  30. package/dist/oauth/cachePolicy.js.map +1 -1
  31. package/dist/worker/createMantleWorker.d.ts +3 -3
  32. package/dist/worker/createMantleWorker.d.ts.map +1 -1
  33. package/dist/worker/createMantleWorker.js +14 -1
  34. package/dist/worker/createMantleWorker.js.map +1 -1
  35. package/package.json +4 -4
  36. package/dist/assets/aotterFavicon.d.ts +0 -2
  37. package/dist/assets/aotterFavicon.d.ts.map +0 -1
  38. package/dist/assets/aotterFavicon.js +0 -5
  39. package/dist/assets/aotterFavicon.js.map +0 -1
@@ -1,11 +1,9 @@
1
- import { DiagnosticError, HTTP_STATUS_BY_CODE, MANTLE_REF_KEYWORD, MCP_HINT_KEYWORD, VIEW_PARAMS_RESERVED, isMediaMcpHint, meetsRole, redactForWire, runtimeDiagnostic, checkSchemaListFilter, schemaSortableFields, } from "@aotter/mantle-spec";
1
+ import { DiagnosticError, HTTP_STATUS_BY_CODE, MANTLE_REF_KEYWORD, MCP_HINT_KEYWORD, VIEW_PARAMS_RESERVED, isMediaMcpHint, meetsRole, redactForWire, runtimeDiagnostic, checkSchemaAdminUi, checkViewAdminUi, schemaSortableFields, } from "@aotter/mantle-spec";
2
2
  import { ViewParamCoercionError, coerceViewParams, compilePathMatcher, evaluateAuthAll, } from "@aotter/mantle-runtime";
3
- import { indexHtml } from "@aotter/mantle-admin-ui";
4
3
  import { resolveCaller } from "./resolveCaller.js";
5
4
  import { runMantleUseCase } from "./runMantleUseCase.js";
6
5
  import { decodeMemberCursor, STAFF_ROLE_SET, } from "../auth/createAuth.js";
7
6
  import { rejectCrossOriginMutation } from "../auth/rejectCrossOriginMutation.js";
8
- import { AOTTER_FAVICON_SVG } from "../assets/aotterFavicon.js";
9
7
  const [PAGE_PARAM, SHOW_PARAM] = VIEW_PARAMS_RESERVED;
10
8
  /** Mount HTTP Triggers + Views + the Better Auth admin surface.
11
9
  * HTTP Triggers resolve configured credentials and sessions through
@@ -35,7 +33,7 @@ export function mountServerEndpoints(app, ref) {
35
33
  // register under the guarded `/admin/api/views/<name>` route
36
34
  // inside `mountAdminBetterAuth` (which owns the staff gate).
37
35
  // Public (default) Views keep the public REST surface.
38
- if (v.spec.surface === "staff")
36
+ if (v.spec.surface !== "public")
39
37
  continue;
40
38
  const viewName = v.metadata.name;
41
39
  app.get(`/api/views/${viewName}`, async (c) => {
@@ -48,15 +46,11 @@ export function mountServerEndpoints(app, ref) {
48
46
  }
49
47
  function mountAdminBetterAuth(app, ref, auth) {
50
48
  const authBasePath = auth.basePath;
51
- const spa = () => new Response(indexHtml, {
52
- headers: { "content-type": "text/html; charset=utf-8" },
53
- });
54
- app.get("/favicon.svg", () => new Response(AOTTER_FAVICON_SVG, {
55
- headers: {
56
- "content-type": "image/svg+xml; charset=utf-8",
57
- "cache-control": "public, max-age=86400",
58
- },
59
- }));
49
+ const spa = async (c) => {
50
+ const runtime = await ref.get();
51
+ const asset = await runtime.assets.fetch(new Request(new URL("/_mantle/admin/index.html", c.req.url)));
52
+ return asset ?? new Response("Mantle Admin assets are missing; run `mantle generate` and configure the ASSETS binding.", { status: 503, headers: { "cache-control": "private, no-store" } });
53
+ };
60
54
  // Public read-only manifest of registered sign-in methods. The admin
61
55
  // SPA hits this on sign-in-page mount so it can render per-method
62
56
  // sections without baking the method list into its build. No secrets
@@ -103,6 +97,23 @@ function mountAdminBetterAuth(app, ref, auth) {
103
97
  // need to repeat per request.
104
98
  const schemas = ref.manifests.filter((m) => m.kind === "Schema");
105
99
  const schemasByName = new Map(schemas.map((s) => [s.metadata.name, s]));
100
+ const assertMutableSchema = (path, schema) => {
101
+ if (schema?.spec.schema.readOnly !== true)
102
+ return;
103
+ throw new DiagnosticError(runtimeDiagnostic({
104
+ code: "CONFLICT",
105
+ severity: "error",
106
+ path,
107
+ value: schema.metadata.name,
108
+ expected: "a Schema without root readOnly: true",
109
+ message: `Schema '${schema.metadata.name}' is read-only on generic authoring surfaces; use its declared Procedures.`,
110
+ }));
111
+ };
112
+ const readMutableEntry = async (runtime, id, path) => {
113
+ const entry = await runtime.getEntry.execute({ id });
114
+ assertMutableSchema(path, schemasByName.get(entry.collection));
115
+ return entry;
116
+ };
106
117
  const collections = schemas
107
118
  .filter((s) => !s.spec.translates)
108
119
  .map((s) => adminEditorCollection(s, schemas));
@@ -124,7 +135,7 @@ function mountAdminBetterAuth(app, ref, auth) {
124
135
  // "precompute at mount from ref.manifests, list on GET") and needs
125
136
  // no changes to the `SiteInfo` type or its query key.
126
137
  // Report-sidebar source (#433): ONLY `surface: staff` Views. Public
127
- // storefront Views (default surface) auto-mount on the public REST
138
+ // storefront Views explicitly marked public auto-mount on the public REST
128
139
  // path and must not appear in the admin report sidebar — listing
129
140
  // them was noise + broke on param-driven storefront Views (see #433).
130
141
  const staffViews = ref.manifests
@@ -132,9 +143,10 @@ function mountAdminBetterAuth(app, ref, auth) {
132
143
  const viewsManifest = staffViews.map((v) => ({
133
144
  name: v.metadata.name,
134
145
  title: v.spec.title ?? null,
135
- from: v.spec.from,
146
+ from: v.spec.from ?? null,
136
147
  params: v.spec.params ?? null,
137
148
  fields: v.spec.fields ?? null,
149
+ list: checkViewAdminUi(v).list,
138
150
  }));
139
151
  const guarded = (method, path, body) => {
140
152
  app.on(method.toUpperCase(), path, async (c) => {
@@ -296,6 +308,11 @@ function mountAdminBetterAuth(app, ref, auth) {
296
308
  const waitUntil = readWaitUntil(c);
297
309
  return handleViewRequest(c.req.raw, runtime, viewName, ref, c.env, waitUntil, "/admin/api/views");
298
310
  });
311
+ guarded("get", `/admin/api/views/${viewName}/export`, async (c) => {
312
+ const runtime = await ref.get();
313
+ const waitUntil = readWaitUntil(c);
314
+ return handleViewRequest(c.req.raw, runtime, viewName, ref, c.env, waitUntil, "/admin/api/views", true);
315
+ });
299
316
  }
300
317
  guarded("get", "/admin/api/operations", (c, gate) => Response.json({
301
318
  operations: operations.filter((op) => evaluateAuthAll(op.procedure.spec.requires, adminHandlerContext(c, gate), `GET /admin/api/operations/${op.name}`, "runtime") === null).map((op) => ({
@@ -303,6 +320,7 @@ function mountAdminBetterAuth(app, ref, auth) {
303
320
  title: op.title,
304
321
  description: op.description,
305
322
  input: op.input,
323
+ uiSchema: op.uiSchema,
306
324
  triggers: op.triggers,
307
325
  rowBindings: op.rowBindings,
308
326
  })),
@@ -480,25 +498,49 @@ function mountAdminBetterAuth(app, ref, auth) {
480
498
  }, { status: 404 });
481
499
  }
482
500
  const runtime = await ref.get();
501
+ const statusQuery = c.req.query("status");
502
+ const filterField = c.req.query("filter_field");
503
+ const filterValue = c.req.query("filter_value");
504
+ if (Boolean(filterField) !== Boolean(filterValue)) {
505
+ return Response.json({
506
+ ok: false,
507
+ diagnostic: runtimeDiagnostic({
508
+ code: "INPUT_VALIDATION_FAILED",
509
+ severity: "error",
510
+ path: "GET /admin/api/entries/export",
511
+ expected: "filter_field and filter_value together",
512
+ message: "List filters require both `filter_field` and `filter_value`.",
513
+ }),
514
+ }, { status: 400 });
515
+ }
516
+ const listOptions = {
517
+ collection,
518
+ status: statusQuery && statusQuery !== "all" ? statusQuery : undefined,
519
+ search: c.req.query("search") || undefined,
520
+ filter: filterField && filterValue ? { field: filterField, value: filterValue } : undefined,
521
+ sort: {
522
+ field: c.req.query("sort") || "updatedAt",
523
+ direction: c.req.query("direction") === "asc" ? "asc" : "desc",
524
+ },
525
+ };
483
526
  const propertyNames = Object.keys(schema.spec.schema.properties ?? {});
484
527
  const columns = ["id", "status", "version", "updated_at", ...propertyNames];
485
- const lines = [csvRow(columns)];
486
- let cursor;
487
- do {
488
- const page = await runtime.listEntries.executePage({ collection, cursor });
489
- for (const row of page.rows) {
490
- lines.push(csvRow(columns.map((column) => csvValue(column, row, propertyNames))));
528
+ let page = await runtime.listEntries.executePage({ ...listOptions, limit: 100 });
529
+ async function* chunks() {
530
+ while (true) {
531
+ if (page.rows.length > 0) {
532
+ yield page.rows.map((row) => csvRow(columns.map((column) => csvValue(column, row, propertyNames)))).join("\r\n") + "\r\n";
533
+ }
534
+ if (!page.nextCursor)
535
+ return;
536
+ page = await runtime.listEntries.executePage({
537
+ ...listOptions,
538
+ limit: 100,
539
+ cursor: page.nextCursor,
540
+ });
491
541
  }
492
- cursor = page.nextCursor;
493
- } while (cursor);
494
- const csv = "" + lines.join("\r\n") + "\r\n";
495
- return new Response(csv, {
496
- status: 200,
497
- headers: {
498
- "content-type": "text/csv; charset=utf-8",
499
- "content-disposition": `attachment; filename="${collection}.csv"`,
500
- },
501
- });
542
+ }
543
+ return csvDownloadResponse(collection, columns, chunks());
502
544
  });
503
545
  guarded("get", "/admin/api/entries/:id", async (c) => runMantleUseCase(`GET /admin/api/entries/${c.req.param("id")}`, async () => {
504
546
  const runtime = await ref.get();
@@ -518,6 +560,7 @@ function mountAdminBetterAuth(app, ref, auth) {
518
560
  message: "A non-empty `collection` is required.",
519
561
  }));
520
562
  }
563
+ assertMutableSchema("POST /admin/api/entries", schemasByName.get(body.collection));
521
564
  if (gate.role === "contributor" &&
522
565
  (schemasByName.get(body.collection)?.spec.lifecycle ?? "publishing") === "operational") {
523
566
  throw new DiagnosticError(adminRoleDiagnostic("POST /admin/api/entries", "editor", "Contributors can create drafts, not operational records."));
@@ -534,8 +577,8 @@ function mountAdminBetterAuth(app, ref, auth) {
534
577
  guarded("patch", "/admin/api/entries/:id", async (c, gate) => runMantleUseCase(`PATCH /admin/api/entries/${c.req.param("id")}`, async () => {
535
578
  const runtime = await ref.get();
536
579
  const id = c.req.param("id");
580
+ const current = await readMutableEntry(runtime, id, `PATCH /admin/api/entries/${id}`);
537
581
  if (gate.role === "contributor") {
538
- const current = await runtime.getEntry.execute({ id });
539
582
  const lifecycle = schemasByName.get(current.collection)?.spec.lifecycle ?? "publishing";
540
583
  if (lifecycle === "operational" || current.status !== "draft") {
541
584
  throw new DiagnosticError(adminRoleDiagnostic(`PATCH /admin/api/entries/${id}`, "editor", "Contributors can edit drafts only."));
@@ -554,6 +597,7 @@ function mountAdminBetterAuth(app, ref, auth) {
554
597
  roleGuarded("post", "/admin/api/entries/:id/publish", "editor", async (c, gate) => runMantleUseCase(`POST /admin/api/entries/${c.req.param("id")}/publish`, async () => {
555
598
  const runtime = await ref.get();
556
599
  const id = c.req.param("id");
600
+ await readMutableEntry(runtime, id, `POST /admin/api/entries/${id}/publish`);
557
601
  const body = await c.req.raw.json().catch(() => ({}));
558
602
  const row = await runtime.requestPublish.execute({
559
603
  id,
@@ -565,6 +609,7 @@ function mountAdminBetterAuth(app, ref, auth) {
565
609
  roleGuarded("post", "/admin/api/entries/:id/unpublish", "editor", async (c, gate) => runMantleUseCase(`POST /admin/api/entries/${c.req.param("id")}/unpublish`, async () => {
566
610
  const runtime = await ref.get();
567
611
  const id = c.req.param("id");
612
+ await readMutableEntry(runtime, id, `POST /admin/api/entries/${id}/unpublish`);
568
613
  const body = await c.req.raw.json().catch(() => ({}));
569
614
  const row = await runtime.unpublish.execute({
570
615
  id,
@@ -576,6 +621,7 @@ function mountAdminBetterAuth(app, ref, auth) {
576
621
  roleGuarded("delete", "/admin/api/entries/:id", "editor", async (c, gate) => runMantleUseCase(`DELETE /admin/api/entries/${c.req.param("id")}`, async () => {
577
622
  const runtime = await ref.get();
578
623
  const id = c.req.param("id");
624
+ await readMutableEntry(runtime, id, `DELETE /admin/api/entries/${id}`);
579
625
  const body = await c.req.raw.json().catch(() => ({}));
580
626
  return runtime.deleteEntry.execute({
581
627
  id,
@@ -805,6 +851,7 @@ function discoverStaffOperations(manifests, schemasByName) {
805
851
  title: procedure.spec.title ?? null,
806
852
  description: procedure.spec.description ?? null,
807
853
  input: procedure.spec.input,
854
+ uiSchema: procedure.spec.uiSchema ?? null,
808
855
  triggers: [...kinds],
809
856
  rowBindings: discoverRowBindings(procedure, schemasByName),
810
857
  procedure,
@@ -895,9 +942,7 @@ function adminEntryTitle(data, schema) {
895
942
  const key = titleFieldKey(data, schema);
896
943
  return key ? data[key] : null;
897
944
  }
898
- /** Which data key `adminEntryTitle` would read, or `null` if none
899
- * matched. Split out from `adminEntryTitle` so `adminDataPreview` can
900
- * skip the same property instead of repeating it in a data column. */
945
+ /** Which data key publishing-list `adminEntryTitle` reads. */
901
946
  function titleFieldKey(data, schema) {
902
947
  if (typeof data.title === "string" && data.title)
903
948
  return "title";
@@ -921,33 +966,13 @@ function titleFieldKey(data, schema) {
921
966
  }
922
967
  return null;
923
968
  }
924
- /** Schema property names that collide in MEANING with a system column
925
- * the admin list already renders unconditionally (the "updated"
926
- * column reads the reserved `updatedAt` storage column, formatted as
927
- * `row.updated_at`). A schema-declared `required` property with one
928
- * of these exact names would otherwise show up a SECOND time as a
929
- * raw data-preview column — same value, no title-cased header — right
930
- * next to the system column (#443). NAME-based only, on purpose: no
931
- * fuzzy/semantic matching, just these two well-known reserved names. */
932
- const DATA_PREVIEW_SYSTEM_COLUMN_NAMES = new Set(["updatedAt", "createdAt"]);
933
- /** Operational collections have no title/status
934
- * workflow worth a dedicated column — instead the admin list shows up
935
- * to 3 raw data columns. Mirrors the client-side column-picking rule
936
- * in `collection-view.tsx`: first 3 `required` properties, skipping
937
- * the schema-stable title field and the system-column-name collisions
938
- * above (#443). Kept small on the wire on purpose — this is a
939
- * preview, not the full entry. */
969
+ /** Operational previews contain exactly the manifest-declared list
970
+ * fields. Undeclared lists stay metadata-only. */
940
971
  function adminDataPreview(data, manifest) {
941
972
  if (!manifest || manifest.spec.lifecycle !== "operational")
942
973
  return undefined;
943
- const schema = manifest.spec.schema;
944
- // Schema-stable skip (not per-row): rows with a blank title field
945
- // must still produce the same columns as every other row, or the
946
- // client's fixed headers drift out of sync with the values.
947
- const titleKey = schemaTitleKey(schema);
948
- const fields = (schema.required ?? [])
949
- .filter((key) => key !== titleKey && !DATA_PREVIEW_SYSTEM_COLUMN_NAMES.has(key))
950
- .slice(0, 3);
974
+ const list = checkSchemaAdminUi(manifest).list;
975
+ const fields = [...(list.primaryField ? [list.primaryField] : []), ...list.columns];
951
976
  if (fields.length === 0)
952
977
  return undefined;
953
978
  const preview = {};
@@ -955,23 +980,6 @@ function adminDataPreview(data, manifest) {
955
980
  preview[key] = data[key];
956
981
  return preview;
957
982
  }
958
- /** The property a row's title comes from, derived from the SCHEMA
959
- * alone (no row data): literal `title`/`name`/`slug` when declared,
960
- * else the first required string-typed property. Stable across all
961
- * rows of a collection, so preview columns never vary per row. */
962
- function schemaTitleKey(schema) {
963
- const properties = schema.properties ?? {};
964
- for (const key of ["title", "name", "slug"]) {
965
- if (key in properties)
966
- return key;
967
- }
968
- for (const key of schema.required ?? []) {
969
- const fieldSchema = properties[key];
970
- if (fieldSchema && isStringTypedSchema(fieldSchema))
971
- return key;
972
- }
973
- return null;
974
- }
975
983
  function isStringTypedSchema(schema) {
976
984
  const rawType = schema.type;
977
985
  const types = Array.isArray(rawType) ? rawType : rawType ? [rawType] : [];
@@ -985,7 +993,9 @@ function adminListItem(row, schemasByName, translationLocales = []) {
985
993
  locale: row.locale ?? null,
986
994
  status: row.status,
987
995
  version: row.version,
988
- title: adminEntryTitle(row.data, manifest?.spec.schema),
996
+ title: manifest?.spec.lifecycle === "operational"
997
+ ? null
998
+ : adminEntryTitle(row.data, manifest?.spec.schema),
989
999
  updated_at: row.updatedAt,
990
1000
  translation_locales: translationLocales,
991
1001
  data_preview: adminDataPreview(row.data, manifest),
@@ -1001,6 +1011,41 @@ function csvField(value) {
1001
1011
  function csvRow(fields) {
1002
1012
  return fields.map(csvField).join(",");
1003
1013
  }
1014
+ function csvDownloadResponse(filename, columns, chunks) {
1015
+ const encoder = new TextEncoder();
1016
+ const iterator = chunks[Symbol.asyncIterator]();
1017
+ const body = new ReadableStream({
1018
+ start(controller) {
1019
+ controller.enqueue(encoder.encode(`${csvRow(columns)}\r\n`));
1020
+ },
1021
+ async pull(controller) {
1022
+ const next = await iterator.next();
1023
+ if (next.done)
1024
+ controller.close();
1025
+ else
1026
+ controller.enqueue(encoder.encode(next.value));
1027
+ },
1028
+ async cancel() {
1029
+ await iterator.return?.();
1030
+ },
1031
+ });
1032
+ return new Response(body, {
1033
+ status: 200,
1034
+ headers: {
1035
+ "content-type": "text/csv; charset=utf-8",
1036
+ "content-disposition": `attachment; filename="${filename}.csv"`,
1037
+ },
1038
+ });
1039
+ }
1040
+ function viewCsvValue(value) {
1041
+ if (value === undefined || value === null)
1042
+ return "";
1043
+ if (typeof value === "string")
1044
+ return value;
1045
+ if (typeof value === "number" || typeof value === "boolean")
1046
+ return String(value);
1047
+ return JSON.stringify(value);
1048
+ }
1004
1049
  /** Reads one CSV column's value off an entry row. The four leading
1005
1050
  * columns are row metadata; everything else is a Schema property
1006
1051
  * read from `data`. Non-scalar values (objects, arrays) are
@@ -1046,6 +1091,7 @@ async function entryEditorPayload(runtime, row, schemas) {
1046
1091
  };
1047
1092
  }
1048
1093
  function adminEditorCollection(schema, schemas) {
1094
+ const adminUi = checkSchemaAdminUi(schema);
1049
1095
  return {
1050
1096
  name: schema.metadata.name,
1051
1097
  title: schema.spec.title,
@@ -1059,7 +1105,8 @@ function adminEditorCollection(schema, schemas) {
1059
1105
  uiSchema: schema.spec.uiSchema ?? null,
1060
1106
  mediaFields: mediaFieldsForCollection(schema, schemas),
1061
1107
  sortableFields: schemaSortableFields(schema),
1062
- filter: checkSchemaListFilter(schema).filter,
1108
+ filter: adminUi.filter,
1109
+ list: adminUi.list,
1063
1110
  };
1064
1111
  }
1065
1112
  function adminEditorEntry(row) {
@@ -1320,7 +1367,7 @@ async function handleViewRequest(req, runtime, viewName, ref, env, waitUntil,
1320
1367
  // the same handler serves BOTH the public mount (`/api/views`) and
1321
1368
  // the staff mount (`/admin/api/views`); hardcoding the public prefix
1322
1369
  // here mislabels staff-View diagnostics + pathPrefix (#F7).
1323
- mountPath) {
1370
+ mountPath, exportCsv = false) {
1324
1371
  const view = runtime.viewsByName.get(viewName);
1325
1372
  if (!view) {
1326
1373
  return jsonError({ status: 500, code: "INTERNAL_ERROR", message: `View '${viewName}' missing post-boot.` });
@@ -1344,8 +1391,12 @@ mountPath) {
1344
1391
  const page = parsePositiveInt(url.searchParams.get(PAGE_PARAM));
1345
1392
  const show = parsePositiveInt(url.searchParams.get(SHOW_PARAM));
1346
1393
  let params;
1394
+ let listQuery;
1347
1395
  try {
1348
1396
  params = coerceViewParams(view, url.searchParams);
1397
+ listQuery = mountPath === "/admin/api/views"
1398
+ ? readViewListQuery(view, url.searchParams)
1399
+ : { filters: [] };
1349
1400
  }
1350
1401
  catch (err) {
1351
1402
  if (err instanceof ViewParamCoercionError) {
@@ -1368,12 +1419,48 @@ mountPath) {
1368
1419
  }
1369
1420
  throw err;
1370
1421
  }
1371
- const result = await runtime.executeView.execute({
1422
+ const execute = (requestedPage) => runtime.executeView.execute({
1372
1423
  view,
1373
1424
  pathPrefix: viewPath,
1374
- options: { params, page, show },
1425
+ options: {
1426
+ params,
1427
+ page: requestedPage,
1428
+ show: exportCsv ? view.spec.limit : show,
1429
+ search: listQuery.search,
1430
+ filters: listQuery.filters,
1431
+ },
1375
1432
  ctx,
1376
1433
  });
1434
+ if (exportCsv) {
1435
+ const result = await execute(1);
1436
+ if (!result.ok) {
1437
+ const status = HTTP_STATUS_BY_CODE[result.diagnostic.code] ?? 500;
1438
+ return Response.json({ ok: false, diagnostic: redactForWire(result.diagnostic) }, { status });
1439
+ }
1440
+ const declaredColumns = checkViewAdminUi(view).list.columns;
1441
+ const columns = declaredColumns.length > 0
1442
+ ? [...declaredColumns]
1443
+ : view.spec.fields?.length
1444
+ ? [...view.spec.fields]
1445
+ : [...new Set(result.result.rows.flatMap((row) => Object.keys(row)))];
1446
+ let current = result.result;
1447
+ let exportPage = 1;
1448
+ async function* chunks() {
1449
+ while (true) {
1450
+ if (current.rows.length > 0) {
1451
+ yield current.rows.map((row) => csvRow(columns.map((column) => viewCsvValue(row[column])))).join("\r\n") + "\r\n";
1452
+ }
1453
+ if (current.rows.length < current.show)
1454
+ return;
1455
+ const next = await execute(++exportPage);
1456
+ if (!next.ok)
1457
+ throw new DiagnosticError(next.diagnostic);
1458
+ current = next.result;
1459
+ }
1460
+ }
1461
+ return csvDownloadResponse(viewName, columns, chunks());
1462
+ }
1463
+ const result = await execute(page);
1377
1464
  if (result.ok) {
1378
1465
  return Response.json({ ok: true, data: result.result });
1379
1466
  }
@@ -1381,6 +1468,35 @@ mountPath) {
1381
1468
  // Same wire-redaction contract as the HTTP Trigger egress above (#396).
1382
1469
  return Response.json({ ok: false, diagnostic: redactForWire(result.diagnostic) }, { status });
1383
1470
  }
1471
+ function readViewListQuery(view, query) {
1472
+ const list = checkViewAdminUi(view).list;
1473
+ const rawSearch = query.get("search")?.trim() ?? "";
1474
+ if (rawSearch && list.searchFields.length === 0) {
1475
+ throw new ViewParamCoercionError(`View '${view.metadata.name}' does not declare Admin search fields.`);
1476
+ }
1477
+ if (rawSearch.length > 200) {
1478
+ throw new ViewParamCoercionError("View Admin search must be at most 200 characters.");
1479
+ }
1480
+ const allowedFilters = new Set(list.filterFields);
1481
+ for (const key of query.keys()) {
1482
+ if (key.startsWith("filter.") && !allowedFilters.has(key.slice("filter.".length))) {
1483
+ throw new ViewParamCoercionError(`View '${view.metadata.name}' does not declare Admin filter field '${key.slice("filter.".length)}'.`);
1484
+ }
1485
+ }
1486
+ const filters = list.filterFields.flatMap((field) => {
1487
+ const value = query.get(`filter.${field}`)?.trim() ?? "";
1488
+ if (!value)
1489
+ return [];
1490
+ if (value.length > 200) {
1491
+ throw new ViewParamCoercionError(`View Admin filter '${field}' must be at most 200 characters.`);
1492
+ }
1493
+ return [{ field, value }];
1494
+ });
1495
+ return {
1496
+ search: rawSearch ? { term: rawSearch, fields: list.searchFields } : undefined,
1497
+ filters,
1498
+ };
1499
+ }
1384
1500
  function parsePositiveInt(raw) {
1385
1501
  if (raw == null || raw === "")
1386
1502
  return undefined;