@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.
- package/README.md +31 -2
- package/dist/auth/conventionalAuth.d.ts +4 -1
- package/dist/auth/conventionalAuth.d.ts.map +1 -1
- package/dist/auth/conventionalAuth.js +86 -20
- package/dist/auth/conventionalAuth.js.map +1 -1
- package/dist/bindings/conventionalBindings.d.ts.map +1 -1
- package/dist/bindings/conventionalBindings.js +2 -0
- package/dist/bindings/conventionalBindings.js.map +1 -1
- package/dist/mount/bootRuntimeOnce.d.ts.map +1 -1
- package/dist/mount/bootRuntimeOnce.js +1 -0
- package/dist/mount/bootRuntimeOnce.js.map +1 -1
- package/dist/mount/cmsConfig.d.ts +1 -0
- package/dist/mount/cmsConfig.d.ts.map +1 -1
- package/dist/mount/index.d.ts +1 -1
- package/dist/mount/index.d.ts.map +1 -1
- package/dist/mount/index.js.map +1 -1
- package/dist/mount/mountMcp.d.ts.map +1 -1
- package/dist/mount/mountMcp.js +23 -7
- package/dist/mount/mountMcp.js.map +1 -1
- package/dist/mount/mountPublicRoutes.d.ts +8 -3
- package/dist/mount/mountPublicRoutes.d.ts.map +1 -1
- package/dist/mount/mountPublicRoutes.js +209 -28
- package/dist/mount/mountPublicRoutes.js.map +1 -1
- package/dist/mount/mountServerEndpoints.d.ts.map +1 -1
- package/dist/mount/mountServerEndpoints.js +197 -81
- package/dist/mount/mountServerEndpoints.js.map +1 -1
- package/dist/oauth/cachePolicy.d.ts +1 -0
- package/dist/oauth/cachePolicy.d.ts.map +1 -1
- package/dist/oauth/cachePolicy.js +2 -0
- package/dist/oauth/cachePolicy.js.map +1 -1
- package/dist/worker/createMantleWorker.d.ts +3 -3
- package/dist/worker/createMantleWorker.d.ts.map +1 -1
- package/dist/worker/createMantleWorker.js +14 -1
- package/dist/worker/createMantleWorker.js.map +1 -1
- package/package.json +4 -4
- package/dist/assets/aotterFavicon.d.ts +0 -2
- package/dist/assets/aotterFavicon.d.ts.map +0 -1
- package/dist/assets/aotterFavicon.js +0 -5
- 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,
|
|
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
|
|
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 = () =>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
493
|
-
|
|
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`
|
|
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
|
-
/**
|
|
925
|
-
*
|
|
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
|
|
944
|
-
|
|
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:
|
|
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:
|
|
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
|
|
1422
|
+
const execute = (requestedPage) => runtime.executeView.execute({
|
|
1372
1423
|
view,
|
|
1373
1424
|
pathPrefix: viewPath,
|
|
1374
|
-
options: {
|
|
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;
|