@malloy-publisher/create-malloy-package 0.0.2 → 0.0.3
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 +46 -15
- package/dist/index.js +250 -16
- package/package.json +2 -2
- package/templates/AGENTS.md +40 -20
- package/templates/model.custom.xlsx.malloy +147 -0
package/README.md
CHANGED
|
@@ -10,10 +10,33 @@ instructions, and the Malloy agent skills copied in), so you can go from nothing
|
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
mkdir my-data && cd my-data
|
|
13
|
-
npm create @malloy-publisher/malloy-package sales
|
|
13
|
+
npm create @malloy-publisher/malloy-package@latest sales
|
|
14
14
|
npm start
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
Keep the `@latest`. `npm create` and `npx` both resolve through npm's npx cache, and an
|
|
18
|
+
unversioned name is satisfied by any copy already in it, so on a machine that has run
|
|
19
|
+
the command before npm reuses that copy instead of asking the registry. Without
|
|
20
|
+
`@latest` you can scaffold from an old scaffolder, which pins an old server, silently.
|
|
21
|
+
|
|
22
|
+
Because that is silent, the scaffolder also checks for itself. After it has finished
|
|
23
|
+
writing, it asks the npm registry which version is `latest` and prints a note if the one
|
|
24
|
+
you are running is older. That is the only network request this package makes: a plain
|
|
25
|
+
unauthenticated GET of a public package document, sending nothing about you or your
|
|
26
|
+
files. It is bounded at 1.5 seconds and fails open, so no network, a proxy, or a
|
|
27
|
+
registry outage costs you a moment of silence and nothing else.
|
|
28
|
+
|
|
29
|
+
The request is not made at all when `CI` or `NO_UPDATE_NOTIFIER` is set, so a build
|
|
30
|
+
never pays for advice nobody will read, and a machine that has already switched off
|
|
31
|
+
update notices stays switched off. Anywhere else, `CREATE_MALLOY_PACKAGE_NO_UPDATE_CHECK`
|
|
32
|
+
does the same thing:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
CREATE_MALLOY_PACKAGE_NO_UPDATE_CHECK=1 npm create @malloy-publisher/malloy-package@latest sales
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Set any of the three to a value other than `0` or `false`.
|
|
39
|
+
|
|
17
40
|
The workspace files land in the current directory and the package in `./sales`, so you
|
|
18
41
|
run `npm start` from where you created it (no need to `cd` into the package). The
|
|
19
42
|
scaffolder's own last lines are the ones to keep: the app URL, the MCP endpoint, and a
|
|
@@ -48,6 +71,13 @@ whatever that file names, and the generated agent briefing spells out the URLs f
|
|
|
48
71
|
this workspace. That briefing is `AGENTS.md`, or `AGENTS.malloy.md` in a directory
|
|
49
72
|
that already had an `AGENTS.md` of its own; see "Running it again" below.
|
|
50
73
|
|
|
74
|
+
A directory nobody has trusted yet is a second gate, separate from connecting the MCP
|
|
75
|
+
server: Claude Code lists the `malloy_*` tools and then refuses every call, and a
|
|
76
|
+
`.claude/settings.json` allowlist is discarded rather than merged. Start Claude Code
|
|
77
|
+
interactively in the directory once and answer the trust prompt, which is asked once per
|
|
78
|
+
directory. A headless run is never asked, so it cannot clear the gate either. You will
|
|
79
|
+
know it worked when the agent's first Malloy query returns data.
|
|
80
|
+
|
|
51
81
|
## Query it
|
|
52
82
|
|
|
53
83
|
The web UI at http://localhost:4000 is the quickest look. For a check you can script,
|
|
@@ -86,12 +116,13 @@ no route falls through to the web app and answers `200` with an HTML page, which
|
|
|
86
116
|
as success until you look at the body.
|
|
87
117
|
|
|
88
118
|
Agents should reach the same models through MCP rather than curl, which buys them
|
|
89
|
-
schema discovery
|
|
90
|
-
|
|
119
|
+
schema discovery and compile checks. The generated briefing lists those tools. It also
|
|
120
|
+
gives the REST route that recompiles a package after an edit, for an agent with nobody
|
|
121
|
+
around to reconnect its MCP client.
|
|
91
122
|
|
|
92
123
|
## What it creates
|
|
93
124
|
|
|
94
|
-
Running `npm create @malloy-publisher/malloy-package sales` in an empty directory produces:
|
|
125
|
+
Running `npm create @malloy-publisher/malloy-package@latest sales` in an empty directory produces:
|
|
95
126
|
|
|
96
127
|
```
|
|
97
128
|
publisher.config.json the server config, with your package registered
|
|
@@ -282,7 +313,7 @@ and binds `0.0.0.0`. Write the flag and the address with a space between them.
|
|
|
282
313
|
## Options
|
|
283
314
|
|
|
284
315
|
```bash
|
|
285
|
-
npm create @malloy-publisher/malloy-package [name] -- [options]
|
|
316
|
+
npm create @malloy-publisher/malloy-package@latest [name] -- [options]
|
|
286
317
|
```
|
|
287
318
|
|
|
288
319
|
`npm create` parses the command line with npm's own config parser before handing
|
|
@@ -291,26 +322,26 @@ it npm swallows `--force` as one of its own settings and turns `--data mydata.cs
|
|
|
291
322
|
into two stray positional arguments:
|
|
292
323
|
|
|
293
324
|
```bash
|
|
294
|
-
npm create @malloy-publisher/malloy-package sales -- --data mydata.csv
|
|
295
|
-
npm create @malloy-publisher/malloy-package sales -- --client cursor
|
|
296
|
-
npm create @malloy-publisher/malloy-package sales -- --force
|
|
325
|
+
npm create @malloy-publisher/malloy-package@latest sales -- --data mydata.csv
|
|
326
|
+
npm create @malloy-publisher/malloy-package@latest sales -- --client cursor
|
|
327
|
+
npm create @malloy-publisher/malloy-package@latest sales -- --force
|
|
297
328
|
```
|
|
298
329
|
|
|
299
330
|
Running the published bin directly takes the flags as-is, with no separator:
|
|
300
331
|
|
|
301
332
|
```bash
|
|
302
|
-
npx @malloy-publisher/create-malloy-package sales --data mydata.csv
|
|
333
|
+
npx @malloy-publisher/create-malloy-package@latest sales --data mydata.csv
|
|
303
334
|
```
|
|
304
335
|
|
|
305
336
|
- `name` (positional): the package name. Omit it to only set up the agent workspace in
|
|
306
337
|
the current directory (write the MCP connection, agent instructions, and skills)
|
|
307
338
|
without scaffolding a package.
|
|
308
|
-
- `--data <file>`: seed the package from your own CSV, Parquet, or XLSX
|
|
309
|
-
the built-in sample. The file is copied into the package and the
|
|
310
|
-
at it. DuckDB reads all
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
a silently ignored flag.
|
|
339
|
+
- `--data <file>`: seed the package from your own CSV, Parquet, JSON, NDJSON, or XLSX
|
|
340
|
+
file instead of the built-in sample. The file is copied into the package and the
|
|
341
|
+
starter model points at it. DuckDB reads all of them in place, so nothing needs
|
|
342
|
+
converting first; an Excel file is read as its first sheet. It seeds a new package,
|
|
343
|
+
so it requires a package name: it cannot be combined with the setup-only mode above,
|
|
344
|
+
and passing it without a name is an error rather than a silently ignored flag.
|
|
314
345
|
- `--client <claude-code|cursor>`: which agent client to wire up. Defaults to
|
|
315
346
|
`claude-code`. `AGENTS.md` and the skills in `.claude/skills/` are written for every
|
|
316
347
|
client; the MCP config file (`.mcp.json` for Claude Code, `.cursor/mcp.json` for
|
package/dist/index.js
CHANGED
|
@@ -246,6 +246,135 @@ function toMalloyIdentifier(name) {
|
|
|
246
246
|
return identifier;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
// src/registry_check.ts
|
|
250
|
+
import * as http from "node:http";
|
|
251
|
+
import * as https from "node:https";
|
|
252
|
+
var LATEST_URL = "https://registry.npmjs.org/@malloy-publisher/create-malloy-package/latest";
|
|
253
|
+
var TIMEOUT_MS = 1500;
|
|
254
|
+
var MAX_BODY_BYTES = 1e6;
|
|
255
|
+
var OPT_OUT_ENV = [
|
|
256
|
+
"CREATE_MALLOY_PACKAGE_NO_UPDATE_CHECK",
|
|
257
|
+
"CI",
|
|
258
|
+
"NO_UPDATE_NOTIFIER"
|
|
259
|
+
];
|
|
260
|
+
function updateCheckDisabled(env = process.env) {
|
|
261
|
+
return OPT_OUT_ENV.some((name) => {
|
|
262
|
+
const value = env[name]?.trim().toLowerCase();
|
|
263
|
+
return value !== undefined && value !== "" && value !== "0" && value !== "false";
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function parseVersion(v) {
|
|
267
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
|
|
268
|
+
if (!m)
|
|
269
|
+
return;
|
|
270
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
271
|
+
}
|
|
272
|
+
function isOlder(a, b) {
|
|
273
|
+
const left = parseVersion(a);
|
|
274
|
+
const right = parseVersion(b);
|
|
275
|
+
if (!left || !right)
|
|
276
|
+
return;
|
|
277
|
+
for (let i = 0;i < 3; i++) {
|
|
278
|
+
if (left[i] < right[i])
|
|
279
|
+
return true;
|
|
280
|
+
if (left[i] > right[i])
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
function getBody(url, timeoutMs, onCancel) {
|
|
286
|
+
return new Promise((resolve) => {
|
|
287
|
+
let settled = false;
|
|
288
|
+
let deadline;
|
|
289
|
+
const finish = (value) => {
|
|
290
|
+
if (settled)
|
|
291
|
+
return;
|
|
292
|
+
settled = true;
|
|
293
|
+
if (deadline !== undefined)
|
|
294
|
+
clearTimeout(deadline);
|
|
295
|
+
resolve(value);
|
|
296
|
+
};
|
|
297
|
+
try {
|
|
298
|
+
const mod = url.startsWith("http:") ? http : https;
|
|
299
|
+
const req = mod.get(url, { timeout: timeoutMs, headers: { accept: "application/json" } }, (res) => {
|
|
300
|
+
if (res.statusCode !== 200) {
|
|
301
|
+
res.resume();
|
|
302
|
+
finish(undefined);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
let body = "";
|
|
306
|
+
let bytes = 0;
|
|
307
|
+
res.setEncoding("utf8");
|
|
308
|
+
res.on("data", (chunk) => {
|
|
309
|
+
body += chunk;
|
|
310
|
+
bytes += Buffer.byteLength(chunk, "utf8");
|
|
311
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
312
|
+
req.destroy();
|
|
313
|
+
finish(undefined);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
res.on("end", () => finish(body));
|
|
317
|
+
res.on("error", () => finish(undefined));
|
|
318
|
+
});
|
|
319
|
+
deadline = setTimeout(() => {
|
|
320
|
+
req.destroy();
|
|
321
|
+
finish(undefined);
|
|
322
|
+
}, timeoutMs);
|
|
323
|
+
req.on("timeout", () => {
|
|
324
|
+
req.destroy();
|
|
325
|
+
finish(undefined);
|
|
326
|
+
});
|
|
327
|
+
req.on("error", () => finish(undefined));
|
|
328
|
+
onCancel(() => {
|
|
329
|
+
req.destroy();
|
|
330
|
+
finish(undefined);
|
|
331
|
+
});
|
|
332
|
+
} catch {
|
|
333
|
+
finish(undefined);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
async function fetchLatestVersion(url = LATEST_URL, timeoutMs = TIMEOUT_MS, onCancel = () => {}) {
|
|
338
|
+
if (updateCheckDisabled())
|
|
339
|
+
return;
|
|
340
|
+
const body = await getBody(url, timeoutMs, onCancel);
|
|
341
|
+
if (body === undefined)
|
|
342
|
+
return;
|
|
343
|
+
try {
|
|
344
|
+
const parsed = JSON.parse(body);
|
|
345
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
346
|
+
return;
|
|
347
|
+
const version = parsed.version;
|
|
348
|
+
return typeof version === "string" ? version.trim() : undefined;
|
|
349
|
+
} catch {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function startVersionCheck(url = LATEST_URL, timeoutMs = TIMEOUT_MS) {
|
|
354
|
+
let cancel = () => {};
|
|
355
|
+
const result = fetchLatestVersion(url, timeoutMs, (fn) => {
|
|
356
|
+
cancel = fn;
|
|
357
|
+
});
|
|
358
|
+
return { result, cancel: () => cancel() };
|
|
359
|
+
}
|
|
360
|
+
function staleScaffolderWarning(running, latest) {
|
|
361
|
+
if (!latest)
|
|
362
|
+
return;
|
|
363
|
+
if (isOlder(running, latest) !== true)
|
|
364
|
+
return;
|
|
365
|
+
return [
|
|
366
|
+
`This is create-malloy-package ${running}, and npm has ${preview(latest)}.`,
|
|
367
|
+
"",
|
|
368
|
+
"The workspace above was written by the older one, so the server version it",
|
|
369
|
+
"pins is whatever that release pinned. If you did not choose this version on",
|
|
370
|
+
"purpose, name it explicitly and scaffold again:",
|
|
371
|
+
" npm create @malloy-publisher/malloy-package@latest <name>",
|
|
372
|
+
"",
|
|
373
|
+
"(Set CREATE_MALLOY_PACKAGE_NO_UPDATE_CHECK=1 to skip this check entirely.)"
|
|
374
|
+
].join(`
|
|
375
|
+
`);
|
|
376
|
+
}
|
|
377
|
+
|
|
249
378
|
// src/scaffold.ts
|
|
250
379
|
import * as fs4 from "node:fs";
|
|
251
380
|
import * as path3 from "node:path";
|
|
@@ -614,7 +743,7 @@ function createPackage(options, result) {
|
|
|
614
743
|
` + forceDescription(name, modelFile, options.host) + `
|
|
615
744
|
|
|
616
745
|
` + `If you passed --force to \`npm create\`, npm read it as one of its ` + `own settings and it never reached this tool. Options need a \`--\` ` + `in front of them there:
|
|
617
|
-
` + ` npm create @malloy-publisher/malloy-package ${name} -- --force`);
|
|
746
|
+
` + ` npm create @malloy-publisher/malloy-package@latest ${name} -- --force`);
|
|
618
747
|
}
|
|
619
748
|
const dataDir = path3.join(packageDir, "data");
|
|
620
749
|
assertWithinWorkspace(dataDir, options.cwd, `${name}/data`);
|
|
@@ -630,7 +759,11 @@ function createPackage(options, result) {
|
|
|
630
759
|
if (destBase !== sourceBase) {
|
|
631
760
|
result.dataFileRenamedFrom = sourceBase;
|
|
632
761
|
}
|
|
633
|
-
|
|
762
|
+
const siblings = findSiblingDataFiles(options.dataFile);
|
|
763
|
+
if (siblings.length > 0) {
|
|
764
|
+
result.siblingDataFiles = siblings;
|
|
765
|
+
}
|
|
766
|
+
writeFile(path3.join(packageDir, modelFile), renderTemplate(isSpreadsheet(dataPath) ? "model.custom.xlsx.malloy" : "model.custom.malloy", { sourceName, dataPath }), options.cwd);
|
|
634
767
|
} else {
|
|
635
768
|
copyFile(path3.join(templatesDir, "sales.csv"), path3.join(dataDir, "sales.csv"), options.cwd, `${name}/data/sales.csv`);
|
|
636
769
|
dataPath = "data/sales.csv";
|
|
@@ -1160,6 +1293,36 @@ function describeMcpProblem(parses, parsed, config) {
|
|
|
1160
1293
|
function renderAgentsFile(result, host, envPackages) {
|
|
1161
1294
|
const mcpNote = !result.mcpWired ? `\`${result.mcpConfigPath}\` could not be updated automatically, so the \`malloy\` server may still need adding to it by hand.` : host === "cursor" ? "This workspace ships a `.cursor/mcp.json` that points Cursor at the endpoint." : "This workspace ships a `.mcp.json`, so Claude Code offers to connect on first run.";
|
|
1162
1295
|
const reconnectNote = host === "cursor" ? "ask the user to reload the MCP servers from Cursor's settings (the `malloy` server, then Refresh), or to restart Cursor." : "ask the user to run `/mcp`, select the `malloy` server, and choose Reconnect, or to restart Claude Code. That panel reports `Auth: not authenticated` and offers `Authenticate` first, which is a red herring: this endpoint has no auth, and Reconnect is the one that works.";
|
|
1296
|
+
const registrationNote = host === "cursor" ? "" : [
|
|
1297
|
+
"",
|
|
1298
|
+
"If they cannot, ask them to register the server so the directory stops deciding:",
|
|
1299
|
+
"",
|
|
1300
|
+
"```bash",
|
|
1301
|
+
`claude mcp add --transport http malloy http://localhost:${result.mcpPort}/mcp -s user`,
|
|
1302
|
+
"```",
|
|
1303
|
+
"",
|
|
1304
|
+
"That registration is stored per user rather than per project, so the tools follow the",
|
|
1305
|
+
"agent everywhere. Skills need no such escape hatch: they are rescanned as the working",
|
|
1306
|
+
"directory changes, so a session started further up picks them up once work moves into",
|
|
1307
|
+
"this directory.",
|
|
1308
|
+
""
|
|
1309
|
+
].join(`
|
|
1310
|
+
`);
|
|
1311
|
+
const trustNote = host === "cursor" ? "" : [
|
|
1312
|
+
"",
|
|
1313
|
+
"One gate can void the reconnect fix below, and you cannot clear it yourself: if the",
|
|
1314
|
+
"`malloy_*` tools are listed but every call is refused, the usual cause is that this",
|
|
1315
|
+
"workspace has not been trusted, which also discards a `.claude/settings.json` allowlist.",
|
|
1316
|
+
"A human has to start Claude Code interactively in this directory once and answer the",
|
|
1317
|
+
"trust prompt; a non-interactive run is never asked, so it cannot clear the gate either.",
|
|
1318
|
+
"Tell the user and leave the choice to them. You will know the prompt was answered when a",
|
|
1319
|
+
"`malloy_*` call returns data instead of being refused. If that does not change it, the",
|
|
1320
|
+
"other cause is a `malloy` server whose own approval was never given, which the user",
|
|
1321
|
+
"clears in the same `/mcp` panel named below. If the tools are not listed at all, this",
|
|
1322
|
+
"gate is not the cause, so read on.",
|
|
1323
|
+
""
|
|
1324
|
+
].join(`
|
|
1325
|
+
`);
|
|
1163
1326
|
const skillsCount = String(result.skillsInstalled);
|
|
1164
1327
|
const skillsNote = result.skillsInstalled === 0 ? "This run installed no skills into `.claude/skills/` (the scaffolder's output says why), so there are none here to load. Pull the same guidance as MCP prompts from the endpoint above instead." : host === "cursor" ? `\`.claude/skills/\` holds ${skillsCount} Malloy agent skills as real files. Cursor reads \`AGENTS.md\`; these skills are the same guidance broken out by task, and any MCP client can also pull them as prompts from the endpoint above.` : `\`.claude/skills/\` holds ${skillsCount} Malloy agent skills as real files, so Claude Code auto-discovers them. Hosts that read \`AGENTS.md\` rather than Anthropic Agent Skills can pull the same guidance as MCP prompts from the endpoint above.`;
|
|
1165
1328
|
const startCommand = result.hasStartScript ? "npm start" : result.startCommand;
|
|
@@ -1176,6 +1339,8 @@ function renderAgentsFile(result, host, envPackages) {
|
|
|
1176
1339
|
packageSection: packageSection(result, envPackages),
|
|
1177
1340
|
mcpNote,
|
|
1178
1341
|
reconnectNote,
|
|
1342
|
+
registrationNote,
|
|
1343
|
+
trustNote,
|
|
1179
1344
|
skillsNote
|
|
1180
1345
|
});
|
|
1181
1346
|
}
|
|
@@ -1299,14 +1464,36 @@ function claudeFile(agentsFile) {
|
|
|
1299
1464
|
This directory already had an AGENTS.md when the Malloy workspace was ` + `scaffolded, so that file was left exactly as it was and carries none of ` + `the above. ${agentsFile} is the generated briefing; read that one for ` + `anything about Publisher, Malloy, or the MCP endpoint.
|
|
1300
1465
|
`;
|
|
1301
1466
|
}
|
|
1467
|
+
var LOADABLE_DATA_EXTENSIONS = [
|
|
1468
|
+
".csv",
|
|
1469
|
+
".parquet",
|
|
1470
|
+
".json",
|
|
1471
|
+
".ndjson",
|
|
1472
|
+
".xlsx"
|
|
1473
|
+
];
|
|
1474
|
+
function isLoadableDataFile(file) {
|
|
1475
|
+
return LOADABLE_DATA_EXTENSIONS.includes(path3.extname(file).toLowerCase());
|
|
1476
|
+
}
|
|
1477
|
+
function isSpreadsheet(dataPath) {
|
|
1478
|
+
return path3.extname(dataPath).toLowerCase() === ".xlsx";
|
|
1479
|
+
}
|
|
1302
1480
|
function validateDataFile(dataFile) {
|
|
1303
1481
|
if (!fs4.existsSync(dataFile) || !fs4.statSync(dataFile).isFile()) {
|
|
1304
1482
|
throw new ScaffoldError(`--data file not found: ${printable(dataFile)}`);
|
|
1305
1483
|
}
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1484
|
+
if (!isLoadableDataFile(dataFile)) {
|
|
1485
|
+
throw new ScaffoldError(`--data must be one of ${LOADABLE_DATA_EXTENSIONS.join(", ")} (got "${printable(path3.basename(dataFile))}").`);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
function findSiblingDataFiles(dataFile) {
|
|
1489
|
+
let entries;
|
|
1490
|
+
try {
|
|
1491
|
+
entries = fs4.readdirSync(path3.dirname(dataFile));
|
|
1492
|
+
} catch {
|
|
1493
|
+
return [];
|
|
1309
1494
|
}
|
|
1495
|
+
const chosen = path3.basename(dataFile);
|
|
1496
|
+
return entries.filter((e) => e !== chosen && isLoadableDataFile(e)).sort().map(printable);
|
|
1310
1497
|
}
|
|
1311
1498
|
function toNpmName(base) {
|
|
1312
1499
|
const cleaned = base.toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/^[._]+/, "");
|
|
@@ -1357,12 +1544,13 @@ function resolveDataFile(cwd, data) {
|
|
|
1357
1544
|
return;
|
|
1358
1545
|
}
|
|
1359
1546
|
if (data.trim() === "") {
|
|
1360
|
-
throw new ScaffoldError(`--data was given an empty filename. Pass the CSV, Parquet, or XLSX ` + `file to seed the package from, or leave --data off to use the ` + `sample data.`);
|
|
1547
|
+
throw new ScaffoldError(`--data was given an empty filename. Pass the CSV, Parquet, JSON, NDJSON, or XLSX ` + `file to seed the package from, or leave --data off to use the ` + `sample data.`);
|
|
1361
1548
|
}
|
|
1362
1549
|
return path4.resolve(cwd, data);
|
|
1363
1550
|
}
|
|
1364
|
-
function run(name, options) {
|
|
1551
|
+
async function run(name, options) {
|
|
1365
1552
|
const cwd = process.cwd();
|
|
1553
|
+
const versionCheck = startVersionCheck();
|
|
1366
1554
|
const before = snapshotArtifacts(cwd, name);
|
|
1367
1555
|
try {
|
|
1368
1556
|
const result = scaffold({
|
|
@@ -1373,7 +1561,14 @@ function run(name, options) {
|
|
|
1373
1561
|
force: Boolean(options.force)
|
|
1374
1562
|
});
|
|
1375
1563
|
process.stdout.write(formatSuccess(result));
|
|
1564
|
+
const warning = staleScaffolderWarning(version(), await versionCheck.result);
|
|
1565
|
+
if (warning) {
|
|
1566
|
+
process.stdout.write(`
|
|
1567
|
+
${yellow(warning)}
|
|
1568
|
+
`);
|
|
1569
|
+
}
|
|
1376
1570
|
} catch (err) {
|
|
1571
|
+
versionCheck.cancel();
|
|
1377
1572
|
process.stderr.write(formatFailure(err, changesSince(cwd, name, before), Boolean(options.force)));
|
|
1378
1573
|
process.exitCode = 1;
|
|
1379
1574
|
}
|
|
@@ -1664,6 +1859,11 @@ function formatSuccess(result) {
|
|
|
1664
1859
|
if (result.dataFileRenamedFrom) {
|
|
1665
1860
|
lines.push(dim(` ${result.dataFileRenamedFrom} was copied in as ${result.dataPath}, ` + `renamed to the characters a Malloy table path can carry.`));
|
|
1666
1861
|
}
|
|
1862
|
+
if (result.siblingDataFiles) {
|
|
1863
|
+
const shown2 = result.siblingDataFiles.slice(0, 5);
|
|
1864
|
+
const rest = result.siblingDataFiles.length - shown2.length;
|
|
1865
|
+
lines.push(dim(` Not included: ${shown2.join(", ")}${rest > 0 ? ` and ${rest} more` : ""}. --data takes one file; copy any others into ` + `${result.packageName}/data/ and add a source for each.`));
|
|
1866
|
+
}
|
|
1667
1867
|
if (result.configExtended) {
|
|
1668
1868
|
lines.push(`${green("✓")} Registered ${bold(result.packageName)} in the existing publisher.config.json`);
|
|
1669
1869
|
}
|
|
@@ -1699,12 +1899,20 @@ function formatSuccess(result) {
|
|
|
1699
1899
|
lines.push(` ${printable(item)}`);
|
|
1700
1900
|
}
|
|
1701
1901
|
}
|
|
1902
|
+
if (result.dataPath !== undefined && isSpreadsheet(result.dataPath)) {
|
|
1903
|
+
lines.push("");
|
|
1904
|
+
lines.push(yellow("Spreadsheets need one check first:"));
|
|
1905
|
+
lines.push(` Run the ${cyan("overview")} view and compare its row count against what you`);
|
|
1906
|
+
lines.push(` know is in ${printable(result.dataPath)}. If it comes back far too small, the header`);
|
|
1907
|
+
lines.push(" is not on the first row and the model is reading a title or a banner");
|
|
1908
|
+
lines.push(` instead of your data. ${printable(result.modelFile ?? "the model file")} carries the fix inline.`);
|
|
1909
|
+
}
|
|
1702
1910
|
const url = `http://localhost:${result.publisherPort}`;
|
|
1703
1911
|
const environmentIsEmpty = result.envPackageCount === 0;
|
|
1704
1912
|
lines.push("");
|
|
1705
1913
|
lines.push(bold("Next steps:"));
|
|
1706
1914
|
if (environmentIsEmpty) {
|
|
1707
|
-
lines.push(` ${cyan("npx @malloy-publisher/create-malloy-package <name>")} ${dim("add a package; the server has nothing to serve without one")}`);
|
|
1915
|
+
lines.push(` ${cyan("npx @malloy-publisher/create-malloy-package@latest <name>")} ${dim("add a package; the server has nothing to serve without one")}`);
|
|
1708
1916
|
}
|
|
1709
1917
|
if (result.needsReset) {
|
|
1710
1918
|
const served = `re-read the config, so ${result.packageName ?? "the new package"} is served`;
|
|
@@ -1746,6 +1954,9 @@ function formatSuccess(result) {
|
|
|
1746
1954
|
lines.push(` ${cyan(url)} ${dim("explore in the browser")}`);
|
|
1747
1955
|
lines.push("");
|
|
1748
1956
|
lines.push(bold("Check it is ready:"));
|
|
1957
|
+
lines.push(dim(` In a second terminal: the boot command above keeps running until you
|
|
1958
|
+
` + ` stop it. Until the server is up these print nothing at all, not an
|
|
1959
|
+
` + " error, because -s silences curl's connection failure."));
|
|
1749
1960
|
lines.push(` curl -s ${url}/api/v0/status`);
|
|
1750
1961
|
const packagesUrl = `${url}/api/v0/environments/${result.envName}/packages`;
|
|
1751
1962
|
if (environmentIsEmpty) {
|
|
@@ -1786,8 +1997,28 @@ function formatSuccess(result) {
|
|
|
1786
1997
|
}
|
|
1787
1998
|
}
|
|
1788
1999
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
2000
|
+
const trustNamed = result.host !== "cursor";
|
|
2001
|
+
if (trustNamed) {
|
|
2002
|
+
lines.push(yellow(` Trust this workspace: a human has to open Claude Code here interactively
|
|
2003
|
+
` + ` once and answer the trust prompt. Until then the malloy tools can report
|
|
2004
|
+
` + ` connected and still refuse every call, and a permissions allowlist in
|
|
2005
|
+
` + ` .claude/settings.json is ignored. You will know the prompt was answered
|
|
2006
|
+
` + " when a malloy tool returns data instead of refusing."));
|
|
2007
|
+
lines.push("");
|
|
2008
|
+
}
|
|
2009
|
+
lines.push(dim((trustNamed ? ` A different problem: an agent that starts the server itself can't
|
|
2010
|
+
reconnect MCP in that session` : ` An agent that starts the server itself can't reconnect MCP in that
|
|
2011
|
+
session`) + `; ask the user to reconnect it. See ${result.agentsFile}.`));
|
|
2012
|
+
if (trustNamed) {
|
|
2013
|
+
lines.push(dim(` ${result.mcpConfigPath} is only picked up by an agent session that
|
|
2014
|
+
` + ` STARTED in this directory. Launch your agent from here. If you cannot,
|
|
2015
|
+
` + ` register the server so the directory stops mattering (.claude/skills
|
|
2016
|
+
` + " needs nothing: it is rescanned as the working directory changes):"));
|
|
2017
|
+
lines.push(` ${cyan(`claude mcp add --transport http malloy http://localhost:${result.mcpPort}/mcp -s user`)}`);
|
|
2018
|
+
} else {
|
|
2019
|
+
lines.push(dim(` ${result.mcpConfigPath} is only picked up by an agent session that
|
|
2020
|
+
` + " STARTED in this directory. Launch your agent from here."));
|
|
2021
|
+
}
|
|
1791
2022
|
lines.push("");
|
|
1792
2023
|
return lines.join(`
|
|
1793
2024
|
`) + `
|
|
@@ -1806,7 +2037,7 @@ function startNote(result, environmentIsEmpty) {
|
|
|
1806
2037
|
return "start Publisher (web UI + MCP) with this workspace's packages in watch mode";
|
|
1807
2038
|
}
|
|
1808
2039
|
function excessArgumentsHint() {
|
|
1809
|
-
const npmCreateFix = ` ${cyan("npm create @malloy-publisher/malloy-package sales -- --data mydata.csv")}`;
|
|
2040
|
+
const npmCreateFix = ` ${cyan("npm create @malloy-publisher/malloy-package@latest sales -- --data mydata.csv")}`;
|
|
1810
2041
|
if (process.env.npm_command === "init") {
|
|
1811
2042
|
return [
|
|
1812
2043
|
"",
|
|
@@ -1826,13 +2057,13 @@ function excessArgumentsHint() {
|
|
|
1826
2057
|
"",
|
|
1827
2058
|
"With npx there is no separator: it forwards the flags as they are, and a",
|
|
1828
2059
|
"-- would be passed through and counted as another argument.",
|
|
1829
|
-
` ${cyan("npx @malloy-publisher/create-malloy-package sales --data mydata.csv")}`,
|
|
2060
|
+
` ${cyan("npx @malloy-publisher/create-malloy-package@latest sales --data mydata.csv")}`,
|
|
1830
2061
|
""
|
|
1831
2062
|
].join(`
|
|
1832
2063
|
`);
|
|
1833
2064
|
}
|
|
1834
2065
|
var program = new Command;
|
|
1835
|
-
program.name("create-malloy-package").description("Scaffold a Malloy Publisher package and a local agent workspace.").version(version(), "-v, --version").argument("[name]", "package name to create; omit to only wire the agent workspace into the current directory").option("--data <file>", "seed the package from a CSV, Parquet, or XLSX file").addOption(new Option("--client <client>", "agent client to wire up").choices(CLIENTS).default("claude-code")).option("--force", "overwrite existing workspace files instead of keeping them").allowExcessArguments(false).exitOverride((err) => {
|
|
2066
|
+
program.name("create-malloy-package").description("Scaffold a Malloy Publisher package and a local agent workspace.").version(version(), "-v, --version").argument("[name]", "package name to create; omit to only wire the agent workspace into the current directory").option("--data <file>", "seed the package from a CSV, Parquet, JSON, NDJSON, or XLSX file").addOption(new Option("--client <client>", "agent client to wire up").choices(CLIENTS).default("claude-code")).option("--force", "overwrite existing workspace files instead of keeping them").allowExcessArguments(false).exitOverride((err) => {
|
|
1836
2067
|
if (err.code === "commander.excessArguments") {
|
|
1837
2068
|
process.stderr.write(excessArgumentsHint());
|
|
1838
2069
|
}
|
|
@@ -1844,8 +2075,8 @@ The flag that picks the agent client is ${cyan("--client")} (claude-code or curs
|
|
|
1844
2075
|
`);
|
|
1845
2076
|
}
|
|
1846
2077
|
process.exit(err.exitCode);
|
|
1847
|
-
}).action((name, options) => {
|
|
1848
|
-
run(name, options);
|
|
2078
|
+
}).action(async (name, options) => {
|
|
2079
|
+
await run(name, options);
|
|
1849
2080
|
});
|
|
1850
2081
|
function startedAsProgram() {
|
|
1851
2082
|
const entry = process.argv[1];
|
|
@@ -1859,7 +2090,10 @@ function startedAsProgram() {
|
|
|
1859
2090
|
}
|
|
1860
2091
|
}
|
|
1861
2092
|
if (startedAsProgram()) {
|
|
1862
|
-
program.
|
|
2093
|
+
program.parseAsync().catch((err) => {
|
|
2094
|
+
process.stderr.write(formatFailure(err, { created: [], changed: [] }, false));
|
|
2095
|
+
process.exitCode = 1;
|
|
2096
|
+
});
|
|
1863
2097
|
}
|
|
1864
2098
|
export {
|
|
1865
2099
|
snapshotArtifacts,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@malloy-publisher/create-malloy-package",
|
|
3
3
|
"description": "Scaffold a Malloy Publisher package and a local agent workspace, so one command takes you from nothing to an agent that can query your data.",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.3",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"test:e2e": "bun test --timeout 180000 tests/e2e"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@malloy-publisher/skills": "^0.1.
|
|
48
|
+
"@malloy-publisher/skills": "^0.1.2",
|
|
49
49
|
"commander": "^12.1.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
package/templates/AGENTS.md
CHANGED
|
@@ -13,15 +13,24 @@ Everything below talks to a running server, so start it before anything else:
|
|
|
13
13
|
{{startCommand}}
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
That is meant to run Publisher on http://localhost:{{port}} (web UI and REST) with
|
|
17
|
-
MCP endpoint on http://localhost:{{mcpPort}}/mcp, and to mount this workspace's
|
|
18
|
-
packages in watch mode so model edits recompile.
|
|
19
|
-
the command
|
|
20
|
-
`npm` script,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
That is *meant* to run Publisher on http://localhost:{{port}} (web UI and REST) with
|
|
17
|
+
the MCP endpoint on http://localhost:{{mcpPort}}/mcp, and to mount this workspace's
|
|
18
|
+
packages in watch mode so model edits recompile. Meant to, because only some of that
|
|
19
|
+
is written in the command, and this paragraph cannot see what yours says. Go and
|
|
20
|
+
read it, and where it is an `npm` script, read the script `package.json` runs for it.
|
|
21
|
+
Five flags decide whether the rest of this file is true:
|
|
22
|
+
|
|
23
|
+
- `--port` and `--mcp_port` are absent from a command that uses the defaults above,
|
|
24
|
+
which is the usual case. Where either one is present, it moves every URL here.
|
|
25
|
+
- `--watch-env` is not a default, so watch mode is on only if you can see it. Without
|
|
26
|
+
it, an edit on disk is not picked up at all until you reload the package.
|
|
27
|
+
- `--server_root` and `--config` say which workspace is being served. Pointing either
|
|
28
|
+
one outside this directory serves a different workspace entirely, and then nothing
|
|
29
|
+
below describes what you are talking to.
|
|
30
|
+
- `--host` decides whether the endpoint is reachable from off this machine. That one
|
|
31
|
+
has a section of its own further down; read it before you assume this is private.
|
|
32
|
+
|
|
33
|
+
The server settles all of it on boot, printing
|
|
25
34
|
`Publisher server listening at http://<address>:<port>` and a
|
|
26
35
|
`MCP server listening at ...` line beside it, so read those two lines rather than
|
|
27
36
|
taking this paragraph's word for it.
|
|
@@ -32,9 +41,9 @@ returning the old model's rows; it may instead make the source vanish, so every
|
|
|
32
41
|
fails with `Reference to undefined object '<source>'`. Either way the failure goes only
|
|
33
42
|
to the server's stdout, which you are not reading if you started it in the background.
|
|
34
43
|
So a query that succeeds after an edit is not proof the edit compiled, and the rows you
|
|
35
|
-
get back may be the old model's. Compile-check the edit with `malloy_compile`, or
|
|
36
|
-
|
|
37
|
-
reports the failure that watch mode swallowed.
|
|
44
|
+
get back may be the old model's. Compile-check the edit with `malloy_compile`, or reload with
|
|
45
|
+
`malloy_reloadPackage` afterwards (both described below, and reload has a REST form for a
|
|
46
|
+
script or an unattended run); either one reports the failure that watch mode swallowed.
|
|
38
47
|
|
|
39
48
|
Poll until it reports serving rather than assuming a fixed wait; the first run
|
|
40
49
|
downloads the server, so it can take a minute:
|
|
@@ -136,9 +145,13 @@ the model file's own errors too),
|
|
|
136
145
|
watch-mode recompile that failed), and
|
|
137
146
|
`malloy_searchDocs`. {{mcpNote}}
|
|
138
147
|
|
|
139
|
-
REST, for a script or a check that does not need an agent: every model is queryable
|
|
140
|
-
|
|
141
|
-
|
|
148
|
+
REST, for a script or a check that does not need an agent: every model is queryable at
|
|
149
|
+
`POST /api/v0/environments/<env>/packages/<package>/models/<model>/query`, and after a
|
|
150
|
+
model-file edit `GET /api/v0/environments/<env>/packages/<package>?reload=true` recompiles
|
|
151
|
+
the package and comes back 424 with the compile errors when it does not compile. Any other
|
|
152
|
+
non-2xx is a failed check too, and its `message` says what went wrong: a 404 for a package
|
|
153
|
+
name this server does not serve, a 503 for a package it could not load at all. This lists
|
|
154
|
+
what the server has loaded:
|
|
142
155
|
|
|
143
156
|
```bash
|
|
144
157
|
curl -s http://localhost:{{port}}/api/v0/environments/{{envName}}/packages
|
|
@@ -149,16 +162,23 @@ package, the one the scaffolder created on the run that wrote this file. A works
|
|
|
149
162
|
scaffolded more than once serves more than that, so take the packages, sources and
|
|
150
163
|
views you do not see here from the list above and from `malloy_getContext`, never
|
|
151
164
|
from their absence here.
|
|
152
|
-
|
|
165
|
+
{{trustNote}}
|
|
153
166
|
If you started this server yourself in this session, your `malloy_*` tools will not
|
|
154
167
|
appear however long you wait: an MCP client fixes its tool list when it connects, so
|
|
155
168
|
it never saw a server that did not yet exist. You cannot reconnect yourself. Say so,
|
|
156
169
|
and {{reconnectNote}} Do not quietly switch to curl instead and call it done: it
|
|
157
170
|
looks like it is working while hiding a problem the user can clear in seconds, and
|
|
158
|
-
it gives up the grounded discovery
|
|
159
|
-
|
|
160
|
-
above
|
|
161
|
-
|
|
171
|
+
it gives up the grounded discovery and compile checks the tools exist to provide. With
|
|
172
|
+
nobody there to reconnect you, REST is the supported interface rather than a workaround,
|
|
173
|
+
and the reload form above is the same check.
|
|
174
|
+
|
|
175
|
+
There is a second reason the tools can be missing, and it looks identical from here:
|
|
176
|
+
this workspace's MCP config is only discovered by an agent session that **started in
|
|
177
|
+
this directory**. A session rooted anywhere else, a parent directory included, never
|
|
178
|
+
sees the server, and no message anywhere reports it. The two causes need different
|
|
179
|
+
fixes, so tell them apart before acting. If the server is running and the tools were
|
|
180
|
+
never offered at all, ask the user to relaunch the agent from this directory.
|
|
181
|
+
{{registrationNote}}
|
|
162
182
|
## Skills ({{skillsCount}} installed)
|
|
163
183
|
|
|
164
184
|
{{skillsNote}}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// {{sourceName}}: a starter Malloy model over {{dataPath}}, served by Publisher.
|
|
2
|
+
//
|
|
3
|
+
// Check the row count before you trust this. A spreadsheet is only a table when
|
|
4
|
+
// its first row is the header and every row under it is data. Plenty are not: a
|
|
5
|
+
// report title, a "generated on" banner, a merged group header, or a blank line
|
|
6
|
+
// above the header are all ordinary in an export, and none of them is visible
|
|
7
|
+
// from here. DuckDB takes the first row it finds as the column names and stops
|
|
8
|
+
// at the first blank row, so a workbook with thousands of rows can load as a
|
|
9
|
+
// handful, or as one, and nothing anywhere reports a problem: the package loads,
|
|
10
|
+
// the server says it is serving, and the query returns 200 with the wrong number.
|
|
11
|
+
//
|
|
12
|
+
// So run `overview` first and compare `record_count` against what you know is in
|
|
13
|
+
// the file. If it is short, the read is wrong. Fixing it takes two steps.
|
|
14
|
+
//
|
|
15
|
+
// STEP 1: look at the sheet, because the fix needs three things this file cannot
|
|
16
|
+
// tell you: which sheet, which row the header is on, and which column marks a
|
|
17
|
+
// real row. Add this to the bottom of THIS FILE, reload the package, then run
|
|
18
|
+
// `{{sourceName}}_probe -> peek`. Delete it once you have what you need.
|
|
19
|
+
//
|
|
20
|
+
// source: {{sourceName}}_probe is duckdb.sql("""
|
|
21
|
+
// SELECT * FROM read_xlsx(
|
|
22
|
+
// '{{dataPath}}',
|
|
23
|
+
// header = false, range = 'A1:Z25', all_varchar = true
|
|
24
|
+
// )
|
|
25
|
+
// """) extend {
|
|
26
|
+
// view: peek is { select: *; limit: 25 }
|
|
27
|
+
// }
|
|
28
|
+
//
|
|
29
|
+
// It has to go in the file rather than being sent as a one-off query: Publisher
|
|
30
|
+
// refuses raw SQL in an ad-hoc query ("`duckdb.sql(...)` cannot be used in a
|
|
31
|
+
// restricted query"), and only allows it where it is part of a model.
|
|
32
|
+
//
|
|
33
|
+
// `peek` prints the first 25 rows exactly as they sit in the sheet, banner rows
|
|
34
|
+
// and all, so you can count down to the real header row and see which column
|
|
35
|
+
// carries an id.
|
|
36
|
+
//
|
|
37
|
+
// It does NOT tell you which sheet you are looking at. With no `sheet =`,
|
|
38
|
+
// read_xlsx reads the first one and says nothing about it, so a workbook whose
|
|
39
|
+
// first sheet is a cover page or a notes tab hands you a plausible-looking
|
|
40
|
+
// answer about the wrong sheet. There is no function that lists the sheets, so
|
|
41
|
+
// if the workbook has more than one tab, take the name from the tabs along the
|
|
42
|
+
// bottom in Excel and pass `sheet = '...'`.
|
|
43
|
+
//
|
|
44
|
+
// A name that does not exist fails loudly, which is safe, but do not use it to
|
|
45
|
+
// go fishing: the error suggests at most one sheet and it picks by spelling, so
|
|
46
|
+
// it can name the tab nearest your typo rather than the one holding your data.
|
|
47
|
+
//
|
|
48
|
+
// STEP 2: read it properly. Name the sheet and the range so the header is found,
|
|
49
|
+
// AND filter the decoration back out, because an explicit range reads every row
|
|
50
|
+
// in it: the blank spacers, the subtotal rows, the total, and the footnotes
|
|
51
|
+
// underneath. Replace the source below, keeping the `extend` block so `overview`
|
|
52
|
+
// still exists to re-run:
|
|
53
|
+
//
|
|
54
|
+
// source: {{sourceName}} is duckdb.sql("""
|
|
55
|
+
// SELECT * FROM read_xlsx(
|
|
56
|
+
// '{{dataPath}}',
|
|
57
|
+
// sheet = 'Sheet1', -- EDIT: the sheet from step 1 (or drop this line
|
|
58
|
+
// -- entirely if the workbook has only one)
|
|
59
|
+
// header = true,
|
|
60
|
+
// range = 'A5:Z100000', -- EDIT BOTH BOUNDS: A5 is where the real header
|
|
61
|
+
// -- is; Z and 100000 are a cap, not a guess at your
|
|
62
|
+
// -- size. Columns past Z and rows past 100000 are
|
|
63
|
+
// -- dropped silently, which is the failure above
|
|
64
|
+
// -- all over again.
|
|
65
|
+
// all_varchar = true -- see the mixed-column note below
|
|
66
|
+
// )
|
|
67
|
+
// WHERE "Order ID" LIKE 'SO-%' -- EDIT: a column every real row has and the
|
|
68
|
+
// -- decoration does not, and what a real value
|
|
69
|
+
// -- looks like. An id is the usual choice.
|
|
70
|
+
// """) extend {
|
|
71
|
+
// measure: record_count is count()
|
|
72
|
+
// view: overview is { aggregate: record_count }
|
|
73
|
+
// }
|
|
74
|
+
//
|
|
75
|
+
// Then reload the package, check the reload itself came back OK, and only then
|
|
76
|
+
// run `overview` again. A reload that fails to compile leaves the previously
|
|
77
|
+
// compiled model serving, so `overview` answers 200 with the OLD number and it
|
|
78
|
+
// looks as though your edit did nothing. The reason is in the reload response,
|
|
79
|
+
// not in the query.
|
|
80
|
+
//
|
|
81
|
+
// If no column is unique to real rows, exclude the labels instead of matching
|
|
82
|
+
// them, and check the count both ways rather than trusting either:
|
|
83
|
+
//
|
|
84
|
+
// WHERE "Region" IS NOT NULL AND "Region" NOT IN ('Subtotal', 'Total')
|
|
85
|
+
//
|
|
86
|
+
// Getting this wrong is quiet: a filter that also admits the subtotal rows raises
|
|
87
|
+
// `record_count` to something plausible and double-counts every measure built on
|
|
88
|
+
// it. That is the same failure this whole comment is about, so re-run `overview`
|
|
89
|
+
// and check the number, do not assume.
|
|
90
|
+
//
|
|
91
|
+
// Both halves matter. Without the range you get the title row as your header.
|
|
92
|
+
// Without the filter you get the whole range, padded with empty rows.
|
|
93
|
+
// Do not reach for `stop_at_empty`: it stops at the first blank spacer, which is
|
|
94
|
+
// usually somewhere in the middle of the data.
|
|
95
|
+
//
|
|
96
|
+
// `duckdb.table()` takes a plain path and nothing else, which is why every option
|
|
97
|
+
// above needs the SQL form. Other things a real spreadsheet does: a column mixing
|
|
98
|
+
// numbers and text fails at query time rather than at load, which is what
|
|
99
|
+
// `all_varchar = true` avoids; a cell someone typed as text, `$1,234` or `12%`,
|
|
100
|
+
// stays text even where the column looks numeric; dates may be text or Excel
|
|
101
|
+
// serial numbers; and every number is a double. `skill:malloy-gotchas-modeling`
|
|
102
|
+
// has the full list.
|
|
103
|
+
//
|
|
104
|
+
// Note that `all_varchar = true` hands you every column as a string, so a measure
|
|
105
|
+
// cannot just sum it. Do the conversion in the SQL above, where you already are,
|
|
106
|
+
// rather than in the measure:
|
|
107
|
+
//
|
|
108
|
+
// SELECT
|
|
109
|
+
// *,
|
|
110
|
+
// try_cast(regexp_replace("Units Sold", '[^0-9.-]', '', 'g') AS DOUBLE) AS units_sold_num
|
|
111
|
+
// FROM read_xlsx(...)
|
|
112
|
+
//
|
|
113
|
+
// then `measure: total_units is units_sold_num.sum()` in Malloy. `try_cast` is
|
|
114
|
+
// what makes it survive: a real export has cells reading `N/A` or `12 (est)`, and
|
|
115
|
+
// a plain cast throws on the first one at query time. Note the regex keeps the
|
|
116
|
+
// digits out of `12 (est)`, so those rows count as 12; strip the whole cell
|
|
117
|
+
// instead if an estimate should not count.
|
|
118
|
+
//
|
|
119
|
+
// Keep the `-` in that character class. Without it every negative comes back
|
|
120
|
+
// POSITIVE: a refund of `-500` sums as `500`, and a revenue total that quietly
|
|
121
|
+
// adds its credits back is the same class of silent wrongness as the bad read
|
|
122
|
+
// above. Accounting parentheses need a step of their own, because stripping them
|
|
123
|
+
// also leaves a positive:
|
|
124
|
+
//
|
|
125
|
+
// (CASE WHEN "Amount" LIKE '(%)' THEN -1 ELSE 1 END)
|
|
126
|
+
// * try_cast(regexp_replace("Amount", '[^0-9.]', '', 'g') AS DOUBLE) AS amount_num
|
|
127
|
+
//
|
|
128
|
+
// The Malloy-side form is `sum(amount::number)`, not `amount::number.sum()`,
|
|
129
|
+
// which does not parse. It is fine on a clean column and throws on a dirty one,
|
|
130
|
+
// which is why the SQL layer is the better place.
|
|
131
|
+
// (`ignore_errors = true` on read_xlsx is the other way: it keeps real types and
|
|
132
|
+
// nulls the cells that do not fit.)
|
|
133
|
+
//
|
|
134
|
+
// Beyond that this is the same starting point as any seeded package: the
|
|
135
|
+
// scaffolder does not read your columns, so it begins with a row count. Build it
|
|
136
|
+
// out for your columns: group_by a column to break the count down, and define
|
|
137
|
+
// measures over the numeric ones. Validate edits with malloy_compile and run new
|
|
138
|
+
// views by name with malloy_reloadPackage. The malloy-modeling skill walks
|
|
139
|
+
// through turning a raw table into a semantic model.
|
|
140
|
+
|
|
141
|
+
source: {{sourceName}} is duckdb.table('{{dataPath}}') extend {
|
|
142
|
+
measure: record_count is count()
|
|
143
|
+
|
|
144
|
+
view: overview is {
|
|
145
|
+
aggregate: record_count
|
|
146
|
+
}
|
|
147
|
+
}
|