@olenbetong/appframe-vite 6.2.0 → 6.3.0

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.
@@ -159,7 +159,15 @@ export async function addResource() {
159
159
  expose: exposeIt || undefined,
160
160
  permissions: permissions,
161
161
  maxRecords: maxRecords !== "50" ? Number(maxRecords) : undefined,
162
- sortOrder: sortOrder ? sortOrder.split(",") : undefined,
162
+ sortOrder: sortOrder
163
+ ? sortOrder
164
+ .split(",")
165
+ .filter(Boolean)
166
+ .map((s) => {
167
+ const [field, direction] = s.split(":");
168
+ return direction ? { field, direction } : { field };
169
+ })
170
+ : undefined,
163
171
  fields: selectedFields.length > 0 ? selectedFields : undefined,
164
172
  };
165
173
  // Write to resources.yaml
@@ -153,7 +153,15 @@ export async function editResource(id) {
153
153
  expose: exposeIt || undefined,
154
154
  permissions,
155
155
  maxRecords: maxRecords !== "50" ? Number(maxRecords) : undefined,
156
- sortOrder: sortOrder ? sortOrder.split(",") : undefined,
156
+ sortOrder: sortOrder
157
+ ? sortOrder
158
+ .split(",")
159
+ .filter(Boolean)
160
+ .map((s) => {
161
+ const [field, direction] = s.split(":");
162
+ return direction ? { field, direction } : { field };
163
+ })
164
+ : undefined,
157
165
  fields: selectedFields.length > 0 ? selectedFields : undefined,
158
166
  };
159
167
  // Update resources.yaml
@@ -22,10 +22,10 @@ export async function generateFromConfig(configPath) {
22
22
  let errors = [];
23
23
  for (let { entry } of allEntries) {
24
24
  let options = entryToCLIOptions(entry, resourcesConfig.server ?? hostname);
25
- options.output = resolve(process.cwd(), entry.output);
25
+ options.output = resolve(process.cwd(), entry.output ?? `src/data/${entry.id}.ts`);
26
26
  try {
27
27
  let content = await fetchAndGenerate(entry.resource, options, client);
28
- let outputPath = resolve(process.cwd(), entry.output);
28
+ let outputPath = resolve(process.cwd(), entry.output ?? `src/data/${entry.id}.ts`);
29
29
  await mkdir(dirname(outputPath), { recursive: true });
30
30
  await writeFile(outputPath, content, "utf-8");
31
31
  await formatWithBiome(outputPath);
@@ -38,7 +38,6 @@ export async function generateFromConfig(configPath) {
38
38
  }
39
39
  }
40
40
  if (errors.length > 0) {
41
- console.error(`\n${errors.length} resource(s) failed to generate.`);
42
- process.exit(1);
41
+ throw new Error(`${errors.length} resource(s) failed to generate:\n${errors.map((e) => ` ${e.id}: ${e.error}`).join("\n")}`);
43
42
  }
44
43
  }
package/lib/cli.js CHANGED
@@ -34,7 +34,13 @@ resources
34
34
  .option("-c, --config <path>", "Path to resources config file (default: resources.yaml)")
35
35
  .action(async (opts) => {
36
36
  let { generateFromConfig } = await import("./cli-resources-generate.js");
37
- await generateFromConfig(opts.config);
37
+ try {
38
+ await generateFromConfig(opts.config);
39
+ }
40
+ catch (error) {
41
+ console.error(`\n${error?.message ?? error}`);
42
+ process.exit(1);
43
+ }
38
44
  });
39
45
  resources
40
46
  .command("add")
@@ -0,0 +1,7 @@
1
+ import type { Connect } from "vite";
2
+ /**
3
+ * Creates a Connect middleware that:
4
+ * 1. Serves the appframe-devtools static SPA at `/__appframe_devtools__/`
5
+ * 2. Exposes a REST API at `/__appframe_devtools__/api/resources` for resources.yaml CRUD
6
+ */
7
+ export declare function createDevtoolsMiddleware(hostname: string): Connect.NextHandleFunction;
@@ -0,0 +1,240 @@
1
+ import { createReadStream, existsSync, rmSync } from "node:fs";
2
+ import https from "node:https";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, extname, join } from "node:path";
5
+ import bodyParser from "body-parser";
6
+ import { login } from "./proxy.js";
7
+ import { readResourcesConfig, writeResourcesConfig } from "./resourcesConfig.js";
8
+ const require = createRequire(import.meta.url);
9
+ const ALL_SERVERS = ["dev.obet.no", "stage.obet.no", "test.obet.no"];
10
+ const MIME_TYPES = {
11
+ ".html": "text/html; charset=utf-8",
12
+ ".js": "application/javascript; charset=utf-8",
13
+ ".css": "text/css; charset=utf-8",
14
+ ".json": "application/json; charset=utf-8",
15
+ ".svg": "image/svg+xml",
16
+ ".png": "image/png",
17
+ ".ico": "image/x-icon",
18
+ ".woff2": "font/woff2",
19
+ ".woff": "font/woff",
20
+ };
21
+ function getDevtoolsDist() {
22
+ try {
23
+ const pkgPath = require.resolve("@olenbetong/appframe-devtools/package.json");
24
+ return join(dirname(pkgPath), "dist");
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function sendJson(res, status, data) {
31
+ const body = status === 204 ? "" : JSON.stringify(data);
32
+ res.statusCode = status;
33
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
34
+ res.end(body);
35
+ }
36
+ async function addResourceOnServer(hostname, cookies, dbObjectId, name) {
37
+ const cookieStr = Object.entries(cookies)
38
+ .map(([k, v]) => `${k}=${v}`)
39
+ .join("; ");
40
+ // Name is required by the server; fall back to the DBObjectID itself
41
+ const body = JSON.stringify({
42
+ operation: "create",
43
+ resourceName: "API_Resources",
44
+ // fields + excludeFieldNames match the format generateApiDataHandler sends, which the server requires
45
+ fields: ["PrimKey", "Created", "CreatedBy", "Updated", "UpdatedBy", "CUT", "CDL", "DBObjectID", "Name"],
46
+ excludeFieldNames: true,
47
+ DBObjectID: dbObjectId,
48
+ Name: name ?? dbObjectId,
49
+ });
50
+ return new Promise((resolve) => {
51
+ const req = https.request({
52
+ hostname,
53
+ port: 443,
54
+ path: "/api/data",
55
+ method: "POST",
56
+ headers: {
57
+ "Content-Type": "application/json",
58
+ Accept: "application/json",
59
+ Cookie: cookieStr,
60
+ Origin: `https://${hostname}`,
61
+ "Content-Length": Buffer.byteLength(body),
62
+ },
63
+ }, (incoming) => {
64
+ let raw = "";
65
+ incoming.on("data", (chunk) => {
66
+ raw += chunk;
67
+ });
68
+ incoming.on("end", () => {
69
+ try {
70
+ const json = JSON.parse(raw);
71
+ if (json.error) {
72
+ resolve({ hostname, success: false, error: json.error });
73
+ }
74
+ else {
75
+ resolve({ hostname, success: true });
76
+ }
77
+ }
78
+ catch {
79
+ const ok = (incoming.statusCode ?? 0) < 400;
80
+ resolve({ hostname, success: ok, error: ok ? undefined : raw.slice(0, 200) });
81
+ }
82
+ });
83
+ });
84
+ req.on("error", (err) => resolve({ hostname, success: false, error: err.message }));
85
+ req.write(body);
86
+ req.end();
87
+ });
88
+ }
89
+ async function handleApi(req, res, apiPath, hostname, next) {
90
+ // apiPath is the path after /__appframe_devtools__/api
91
+ // e.g. /config, /resources or /resources/dsMyObject
92
+ const segments = apiPath
93
+ .replace(/^\/resources/, "")
94
+ .split("/")
95
+ .filter(Boolean);
96
+ const method = req.method?.toUpperCase() ?? "GET";
97
+ if (apiPath === "/config" && method === "GET") {
98
+ return sendJson(res, 200, { hostname });
99
+ }
100
+ // Only handle /resources and /resources/:id
101
+ if (!apiPath.startsWith("/resources") && apiPath !== "/catalog") {
102
+ return next();
103
+ }
104
+ const id = segments[0]; // undefined for collection-level routes
105
+ try {
106
+ if (apiPath === "/catalog" && method === "POST") {
107
+ const { dbObjectId, name } = req.body;
108
+ if (!dbObjectId) {
109
+ return sendJson(res, 400, { error: "dbObjectId is required" });
110
+ }
111
+ const username = process.env.APPFRAME_LOGIN ?? "";
112
+ const password = process.env.APPFRAME_PWD ?? "";
113
+ if (!username || !password) {
114
+ return sendJson(res, 500, { error: "APPFRAME_LOGIN and APPFRAME_PWD environment variables must be set" });
115
+ }
116
+ const settled = await Promise.allSettled(ALL_SERVERS.map(async (server) => {
117
+ const cookies = await login(server, username, password, { silent: true });
118
+ return addResourceOnServer(server, cookies, dbObjectId, name);
119
+ }));
120
+ const results = settled.map((r, i) => r.status === "fulfilled"
121
+ ? r.value
122
+ : { hostname: ALL_SERVERS[i], success: false, error: String(r.reason) });
123
+ return sendJson(res, 200, { results });
124
+ }
125
+ if (method === "GET" && !id) {
126
+ const config = await readResourcesConfig();
127
+ return sendJson(res, 200, config);
128
+ }
129
+ if (method === "POST" && !id) {
130
+ const { type, ...entry } = req.body;
131
+ const config = await readResourcesConfig();
132
+ if (type === "procedure") {
133
+ config.procedures = [...(config.procedures ?? []), entry];
134
+ }
135
+ else {
136
+ config.dataObjects = [...(config.dataObjects ?? []), entry];
137
+ }
138
+ await writeResourcesConfig(config);
139
+ return sendJson(res, 201, entry);
140
+ }
141
+ if (method === "PUT" && id) {
142
+ const updates = req.body;
143
+ const config = await readResourcesConfig();
144
+ let found = false;
145
+ for (const list of [config.dataObjects ?? [], config.procedures ?? []]) {
146
+ const idx = list.findIndex((e) => e.id === id);
147
+ if (idx !== -1) {
148
+ list[idx] = { ...list[idx], ...updates };
149
+ found = true;
150
+ break;
151
+ }
152
+ }
153
+ if (!found) {
154
+ return sendJson(res, 404, { error: `Resource '${id}' not found` });
155
+ }
156
+ await writeResourcesConfig(config);
157
+ return sendJson(res, 200, { id, ...updates });
158
+ }
159
+ if (method === "DELETE" && id) {
160
+ const config = await readResourcesConfig();
161
+ let found = false;
162
+ let outputPath;
163
+ for (const key of ["dataObjects", "procedures"]) {
164
+ const list = config[key] ?? [];
165
+ const idx = list.findIndex((e) => e.id === id);
166
+ if (idx !== -1) {
167
+ outputPath = list[idx].output;
168
+ list.splice(idx, 1);
169
+ found = true;
170
+ break;
171
+ }
172
+ }
173
+ if (!found) {
174
+ return sendJson(res, 404, { error: `Resource '${id}' not found` });
175
+ }
176
+ await writeResourcesConfig(config);
177
+ if (outputPath) {
178
+ try {
179
+ rmSync(outputPath, { force: true });
180
+ }
181
+ catch {
182
+ // non-fatal — file may not exist yet
183
+ }
184
+ }
185
+ return sendJson(res, 204, null);
186
+ }
187
+ return next();
188
+ }
189
+ catch (err) {
190
+ return sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
191
+ }
192
+ }
193
+ /**
194
+ * Creates a Connect middleware that:
195
+ * 1. Serves the appframe-devtools static SPA at `/__appframe_devtools__/`
196
+ * 2. Exposes a REST API at `/__appframe_devtools__/api/resources` for resources.yaml CRUD
197
+ */
198
+ export function createDevtoolsMiddleware(hostname) {
199
+ const jsonParser = bodyParser.json();
200
+ const devtoolsDist = getDevtoolsDist();
201
+ return (req, res, next) => {
202
+ // req.url here is the path after /__appframe_devtools__ (Connect strips the prefix)
203
+ const url = req.url ?? "/";
204
+ // REST API
205
+ if (url.startsWith("/api/")) {
206
+ jsonParser(req, res, () => {
207
+ handleApi(req, res, url.slice(4), hostname, next).catch(next);
208
+ });
209
+ return;
210
+ }
211
+ // Static files
212
+ if (!devtoolsDist) {
213
+ res.statusCode = 503;
214
+ res.setHeader("Content-Type", "text/plain");
215
+ res.end("@olenbetong/appframe-devtools not found or not built.\n" +
216
+ "Run: pnpm --filter @olenbetong/appframe-devtools build");
217
+ return;
218
+ }
219
+ const safePath = url.split("?")[0] || "/";
220
+ const filePath = join(devtoolsDist, safePath === "/" ? "index.html" : safePath);
221
+ if (existsSync(filePath)) {
222
+ const mime = MIME_TYPES[extname(filePath)] ?? "application/octet-stream";
223
+ res.setHeader("Content-Type", mime);
224
+ createReadStream(filePath).pipe(res);
225
+ }
226
+ else {
227
+ // SPA fallback - all unknown paths serve index.html
228
+ const indexPath = join(devtoolsDist, "index.html");
229
+ if (existsSync(indexPath)) {
230
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
231
+ createReadStream(indexPath).pipe(res);
232
+ }
233
+ else {
234
+ res.statusCode = 404;
235
+ res.setHeader("Content-Type", "text/plain");
236
+ res.end("DevTools app not built yet.\n" + "Run: pnpm --filter @olenbetong/appframe-devtools build");
237
+ }
238
+ }
239
+ };
240
+ }
package/lib/index.d.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  import type { Plugin } from "vite";
2
2
  import { addAppframeBuildConfig } from "./build.js";
3
3
  import { createDevMiddleware } from "./devServer.js";
4
- export default function appframe(): Plugin;
4
+ export interface AppframePluginOptions {
5
+ /**
6
+ * Whether to automatically generate TypeScript types from the Appframe article
7
+ * when the dev server starts. Set to `false` to skip type generation (e.g. when
8
+ * running Storybook or another tool that doesn't have an article context).
9
+ *
10
+ * @default true
11
+ */
12
+ generateTypes?: boolean;
13
+ }
14
+ export default function appframe(options?: AppframePluginOptions): Plugin;
5
15
  export { addAppframeBuildConfig, createDevMiddleware };
package/lib/index.js CHANGED
@@ -4,6 +4,7 @@ import { watch } from "chokidar";
4
4
  import { addAppframeBuildConfig } from "./build.js";
5
5
  import { generateFromConfig } from "./cli-resources-generate.js";
6
6
  import { createDevMiddleware, getLoginInfo, getProxyRoutes } from "./devServer.js";
7
+ import { createDevtoolsMiddleware } from "./devtoolsServer.js";
7
8
  import { runGenerateTypes } from "./generateTypes.js";
8
9
  import { localizeMiddleware } from "./localization.js";
9
10
  import { checkSession, getLastSession, login } from "./proxy.js";
@@ -31,7 +32,8 @@ catch (error) {
31
32
  console.log(createLogMessage(`failed to watch package.json: ${error.message}`, { type: "warn" }));
32
33
  }
33
34
  const jsonParser = bodyParser.json();
34
- export default function appframe() {
35
+ export default function appframe(options = {}) {
36
+ let { generateTypes = true } = options;
35
37
  return {
36
38
  name: "appframe",
37
39
  resolveId(source) {
@@ -143,7 +145,9 @@ export default function appframe() {
143
145
  let { appframe, hostname, username, password } = await getLoginInfo();
144
146
  server = _server;
145
147
  // Run type generation in the background — doesn't block the dev server from starting.
146
- runGenerateTypes(hostname, username, password, appframe, _server.config.logger);
148
+ if (generateTypes) {
149
+ runGenerateTypes(hostname, username, password, appframe, _server.config.logger);
150
+ }
147
151
  // Watch resources.yaml and regenerate on change
148
152
  let resourcesConfigPath = resolve(process.cwd(), RESOURCES_CONFIG_FILE);
149
153
  if (resourcesWatcher) {
@@ -166,6 +170,8 @@ export default function appframe() {
166
170
  }
167
171
  }, 300);
168
172
  });
173
+ // DevTools panel: serve the devtools app + REST API at /__appframe_devtools__/
174
+ _server.middlewares.use("/__appframe_devtools__", createDevtoolsMiddleware(hostname));
169
175
  _server.middlewares.use(`/api/user/localize/new/${appframe.article.id}`, jsonParser);
170
176
  _server.middlewares.use(`/api/user/localize/new/${appframe.article.id}`, localizeMiddleware);
171
177
  _server.middlewares.use("/data/Logger/LogError", jsonParser);
@@ -182,6 +188,21 @@ export default function appframe() {
182
188
  _server.middlewares.use(createDevMiddleware(_server));
183
189
  };
184
190
  },
191
+ transformIndexHtml(_html, ctx) {
192
+ // Only inject the toolbar in dev mode
193
+ if (command !== "serve")
194
+ return;
195
+ // Only inject for article pages (not the devtools app itself)
196
+ if (ctx.originalUrl?.startsWith("/__appframe_devtools__"))
197
+ return;
198
+ return [
199
+ {
200
+ tag: "script",
201
+ attrs: { src: "/__appframe_devtools__/toolbar.js", defer: true },
202
+ injectTo: "body",
203
+ },
204
+ ];
205
+ },
185
206
  };
186
207
  }
187
208
  export { addAppframeBuildConfig, createDevMiddleware };
@@ -320,7 +320,7 @@ export function getProcedureDefinition(name, procDefinition, options) {
320
320
  output.push(`export type ${paramTypeName} = null | undefined | Record<string, unknown>;\n`);
321
321
  }
322
322
  }
323
- output.push(`export const ${procName} = new ${options.global ? "af." : ""}ProcedureAPI${options.types ? `<${paramTypeName}, ${options.typesJsonReturnType ?? "unknown"}>` : ""}({
323
+ output.push(`export const ${procName} = new ${options.global ? "af." : ""}ProcedureAPI${options.types ? `<${paramTypeName}, ${options.typesJsonReturnType ?? "{ Table?: unknown[] }"}>` : ""}({
324
324
  procedureId: "${name}",
325
325
  parameters: ${JSON.stringify(parameters, null, 2)},
326
326
  timeout: 30000
@@ -539,7 +539,8 @@ export async function fetchResourceDefinition(client, resourceName) {
539
539
  if (!response.ok) {
540
540
  throw new Error(`Failed to fetch resource definition for '${resourceName}': ${response.status} ${response.statusText}`);
541
541
  }
542
- return response.json();
542
+ const json = await response.json();
543
+ return json.success ?? json;
543
544
  }
544
545
  /**
545
546
  * Fetch the resource definition from the server, read types.json overrides, and
@@ -551,7 +552,7 @@ export async function fetchResourceDefinition(client, resourceName) {
551
552
  */
552
553
  export async function fetchAndGenerate(resourceName, options, client) {
553
554
  let definition = await fetchResourceDefinition(client, resourceName);
554
- definition.Parameters = definition.Parameters.filter((p) => !["CUT", "CDL"].includes(p.Name));
555
+ definition.Parameters = (definition.Parameters ?? []).filter((p) => !["CUT", "CDL"].includes(p.Name));
555
556
  let typesJson = {};
556
557
  try {
557
558
  typesJson = await importJson("./types.json", true);
@@ -560,7 +561,8 @@ export async function fetchAndGenerate(resourceName, options, client) {
560
561
  // no types.json — that's fine
561
562
  }
562
563
  options.typesJsonParamOverrides = typesJson.parameterTypes?.[resourceName] ?? {};
563
- options.typesJsonReturnType = typesJson.procedureReturnTypes?.[resourceName] ?? null;
564
+ // Structured returnType from resources.yaml takes precedence over the raw types.json string
565
+ options.typesJsonReturnType = options.typesJsonReturnType ?? typesJson.procedureReturnTypes?.[resourceName] ?? null;
564
566
  return definition.ObjectType === "V"
565
567
  ? getDataObjectDefinition(resourceName, definition, options)
566
568
  : getProcedureDefinition(resourceName, definition, options);
@@ -1,4 +1,24 @@
1
1
  import type { CLIOptions } from "./resourceGenerate.js";
2
+ export type ReturnTypeField = {
3
+ /** TypeScript field name */
4
+ name: string;
5
+ /** TypeScript type, e.g. `string`, `number | null`, `Date` */
6
+ type: string;
7
+ };
8
+ export type ReturnTypeTable = {
9
+ /** Property key when multiple result-sets are returned. Omit for single-table procedures. */
10
+ table?: string;
11
+ fields: ReturnTypeField[];
12
+ };
13
+ /**
14
+ * Build an inline TypeScript type string from a structured returnType definition.
15
+ *
16
+ * The server always returns a DataSet serialized as `{ Table: T[], Table1: U[], ... }`.
17
+ * The first table is always `"Table"`, subsequent ones are `"Table1"`, `"Table2"`, etc.
18
+ * An explicit `table` name on the entry overrides the auto-generated key (for procedures
19
+ * that assign custom DataTable.TableName values).
20
+ */
21
+ export declare function returnTypeToString(returnType: ReturnTypeTable[]): string;
2
22
  export type ResourceEntry = {
3
23
  /** Data object or procedure identifier used in the generated code (e.g. `dsAccountGroups`) */
4
24
  id: string;
@@ -14,8 +34,11 @@ export type ResourceEntry = {
14
34
  permissions?: string;
15
35
  /** Maximum records to fetch (default `50`; use `-1` for all) */
16
36
  maxRecords?: number;
17
- /** Sort order: comma-separated list of `field` or `field:Asc|Desc` */
18
- sortOrder?: string | string[];
37
+ /** Sort order as a list of sort objects */
38
+ sortOrder?: Array<{
39
+ field: string;
40
+ direction?: string;
41
+ }>;
19
42
  /** Name of master data object (optionally `name:importPath` to import from another file) */
20
43
  master?: string;
21
44
  /** Link fields to master data object (comma-separated or array) */
@@ -30,14 +53,19 @@ export type ResourceEntry = {
30
53
  overrides?: string | string[];
31
54
  /** Fetch distinct rows */
32
55
  distinct?: boolean;
33
- /** Aggregate bindings for non-grouped fields: comma-separated or array of `field:AGG` */
34
- aggregates?: string | string[];
35
- /** Group-by fields: comma-separated or array */
36
- groupBy?: string | string[];
56
+ /** Aggregate bindings for non-grouped fields */
57
+ aggregates?: Array<{
58
+ field: string;
59
+ aggregate: string;
60
+ }>;
61
+ /** Group-by fields */
62
+ groupBy?: string[];
37
63
  /** Initial where clause */
38
64
  where?: string;
39
65
  /** Fields to include: comma-separated or array of field names */
40
66
  fields?: string | string[];
67
+ /** Structured return type definition for procedures (generates inline TS type) */
68
+ returnType?: ReturnTypeTable[];
41
69
  };
42
70
  export type ResourcesConfig = {
43
71
  /** Optional server hostname override (defaults to `appframe.proxy.hostname` from package.json) */
@@ -2,6 +2,34 @@ import { existsSync } from "node:fs";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
5
+ /**
6
+ * Build an inline TypeScript type string from a structured returnType definition.
7
+ *
8
+ * The server always returns a DataSet serialized as `{ Table: T[], Table1: U[], ... }`.
9
+ * The first table is always `"Table"`, subsequent ones are `"Table1"`, `"Table2"`, etc.
10
+ * An explicit `table` name on the entry overrides the auto-generated key (for procedures
11
+ * that assign custom DataTable.TableName values).
12
+ */
13
+ export function returnTypeToString(returnType) {
14
+ if (returnType.length === 0) {
15
+ return "unknown";
16
+ }
17
+ function tableRowType(table) {
18
+ if (table.fields.length === 0) {
19
+ return "Record<string, unknown>";
20
+ }
21
+ let props = table.fields.map((f) => `${f.name}: ${f.type}`).join("; ");
22
+ return `{ ${props} }`;
23
+ }
24
+ function tableKey(table, index) {
25
+ if (table.table) {
26
+ return table.table;
27
+ }
28
+ return index === 0 ? "Table" : `Table${index}`;
29
+ }
30
+ let props = returnType.map((t, i) => `${tableKey(t, i)}: ${tableRowType(t)}[]`).join("; ");
31
+ return `{ ${props} }`;
32
+ }
5
33
  // ---------------------------------------------------------------------------
6
34
  // Config file path
7
35
  // ---------------------------------------------------------------------------
@@ -35,6 +63,32 @@ function arrayOrStringToString(value) {
35
63
  return value.join(",");
36
64
  return value;
37
65
  }
66
+ /** Convert sortOrder (new object format, old string/array) to comma-separated CLIOptions string. */
67
+ function sortOrderToString(sortOrder) {
68
+ if (!sortOrder)
69
+ return undefined;
70
+ if (typeof sortOrder === "string")
71
+ return sortOrder;
72
+ if (Array.isArray(sortOrder) && sortOrder.length === 0)
73
+ return undefined;
74
+ if (typeof sortOrder[0] === "string")
75
+ return sortOrder.join(",");
76
+ return sortOrder
77
+ .map((s) => (s.direction ? `${s.field}:${s.direction}` : s.field))
78
+ .join(",");
79
+ }
80
+ /** Convert aggregates (new object format, old string/array) to comma-separated CLIOptions string. */
81
+ function aggregatesToString(aggregates) {
82
+ if (!aggregates)
83
+ return undefined;
84
+ if (typeof aggregates === "string")
85
+ return aggregates;
86
+ if (Array.isArray(aggregates) && aggregates.length === 0)
87
+ return undefined;
88
+ if (typeof aggregates[0] === "string")
89
+ return aggregates.join(",");
90
+ return aggregates.map((a) => `${a.field}:${a.aggregate}`).join(",");
91
+ }
38
92
  /** Convert a `ResourceEntry` into a `CLIOptions`-compatible object for code generation. */
39
93
  export function entryToCLIOptions(entry, hostname) {
40
94
  return {
@@ -44,7 +98,7 @@ export function entryToCLIOptions(entry, hostname) {
44
98
  dynamic: entry.dynamic ?? false,
45
99
  types: entry.types,
46
100
  maxRecords: entry.maxRecords !== undefined ? String(entry.maxRecords) : "50",
47
- sortOrder: arrayOrStringToString(entry.sortOrder),
101
+ sortOrder: sortOrderToString(entry.sortOrder),
48
102
  permissions: entry.permissions,
49
103
  master: entry.master,
50
104
  linkFields: arrayOrStringToString(entry.linkFields),
@@ -52,11 +106,12 @@ export function entryToCLIOptions(entry, hostname) {
52
106
  unique: entry.unique,
53
107
  overrides: arrayOrStringToString(entry.overrides),
54
108
  distinct: entry.distinct,
55
- aggregates: arrayOrStringToString(entry.aggregates),
56
- groupBy: arrayOrStringToString(entry.groupBy),
109
+ aggregates: aggregatesToString(entry.aggregates),
110
+ groupBy: entry.groupBy?.join(","),
57
111
  where: entry.where,
58
112
  fields: arrayOrStringToString(entry.fields) ?? false,
59
- output: resolve(process.cwd(), entry.output),
113
+ output: resolve(process.cwd(), entry.output ?? `src/data/${entry.id}.ts`),
114
+ typesJsonReturnType: entry.returnType ? returnTypeToString(entry.returnType) : undefined,
60
115
  };
61
116
  }
62
117
  /** Convert a `CLIOptions` back to a `ResourceEntry` for saving to YAML. */
@@ -81,8 +136,15 @@ export function cliOptionsToEntry(resource, options) {
81
136
  if (options.maxRecords && options.maxRecords !== "50") {
82
137
  entry.maxRecords = Number(options.maxRecords);
83
138
  }
84
- if (options.sortOrder)
85
- entry.sortOrder = maybeArray(options.sortOrder) ?? options.sortOrder;
139
+ if (options.sortOrder) {
140
+ entry.sortOrder = options.sortOrder
141
+ .split(",")
142
+ .filter(Boolean)
143
+ .map((s) => {
144
+ const [field, direction] = s.split(":");
145
+ return direction ? { field, direction } : { field };
146
+ });
147
+ }
86
148
  if (options.master)
87
149
  entry.master = options.master;
88
150
  if (options.linkFields)
@@ -97,10 +159,17 @@ export function cliOptionsToEntry(resource, options) {
97
159
  entry.overrides = maybeArray(options.overrides) ?? options.overrides;
98
160
  if (options.distinct)
99
161
  entry.distinct = true;
100
- if (options.aggregates)
101
- entry.aggregates = maybeArray(options.aggregates) ?? options.aggregates;
162
+ if (options.aggregates) {
163
+ entry.aggregates = options.aggregates
164
+ .split(",")
165
+ .filter(Boolean)
166
+ .map((a) => {
167
+ const [field, aggregate] = a.split(":");
168
+ return { field, aggregate: aggregate ?? "" };
169
+ });
170
+ }
102
171
  if (options.groupBy)
103
- entry.groupBy = maybeArray(options.groupBy) ?? options.groupBy;
172
+ entry.groupBy = options.groupBy.split(",").filter(Boolean);
104
173
  if (options.where)
105
174
  entry.where = options.where;
106
175
  if (options.fields && typeof options.fields === "string") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/appframe-vite",
3
- "version": "6.2.0",
3
+ "version": "6.3.0",
4
4
  "description": "Tools to use and deploy Vite applications to Appframe",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -29,6 +29,7 @@
29
29
  "author": "Bjørnar Vister Hansen <bvh@olenbetong.no>",
30
30
  "license": "MIT",
31
31
  "dependencies": {
32
+ "@olenbetong/appframe-data": "1.5.0",
32
33
  "body-parser": "^2.2.2",
33
34
  "chalk": "^5.4.1",
34
35
  "chokidar": "^5.0.0",
@@ -40,7 +41,7 @@
40
41
  "jsdom": "29.1.1",
41
42
  "rollup-plugin-visualizer": "^6.0.5",
42
43
  "yaml": "^2.8.4",
43
- "@olenbetong/appframe-data": "1.5.0"
44
+ "@olenbetong/appframe-devtools": "0.1.0"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@types/jsdom": "^27.0.0",