@wix/himalaya-cli 0.923.0 → 0.925.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.
- package/dist/cli.mjs +2 -1
- package/package.json +2 -2
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
- package/templates/community-club-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
- package/templates/community-club-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
- package/templates/community-club-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
- package/templates/community-club-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
- package/templates/owner-lite/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
- package/templates/owner-lite/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
- package/templates/owner-lite/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
- package/templates/owner-lite/tier5-src/himi-owner-lite-ask/live.ts +98 -3
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-ask/blocks.ts +89 -2
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-ask/catalog.ts +155 -4
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-ask/grammar.ts +50 -5
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-ask/live.ts +98 -3
package/dist/cli.mjs
CHANGED
|
@@ -5241,7 +5241,6 @@ function resolveTranslation(value, catalog, sourceCatalog) {
|
|
|
5241
5241
|
var BINDING_SIGILS, BINDING_SIGIL_RE, TRANSLATION_PREFIX, TRANSLATION_KEY_RE;
|
|
5242
5242
|
var init_bindings = __esm({
|
|
5243
5243
|
"../../core/ts/src/bindings.ts"() {
|
|
5244
|
-
"use strict";
|
|
5245
5244
|
BINDING_SIGILS = ["service", "flag", "item", "t"];
|
|
5246
5245
|
BINDING_SIGIL_RE = new RegExp(
|
|
5247
5246
|
`^\\$(?:${BINDING_SIGILS.filter((s) => s !== "t").join("|")})\\b|^\\$t:`
|
|
@@ -18526,6 +18525,7 @@ function shouldRevealError(args) {
|
|
|
18526
18525
|
var EMAIL_RE, URL_HOST_RE, INTEGER_RE, NUMBER_RE;
|
|
18527
18526
|
var init_validation = __esm({
|
|
18528
18527
|
"../../core/ts/src/validation.ts"() {
|
|
18528
|
+
"use strict";
|
|
18529
18529
|
EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/;
|
|
18530
18530
|
URL_HOST_RE = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*(:[0-9]+)?$/;
|
|
18531
18531
|
INTEGER_RE = /^[+-]?[0-9]+$/;
|
|
@@ -21949,6 +21949,7 @@ function validateWorkerSubtree(raw, slotId, policy, actionCatalog) {
|
|
|
21949
21949
|
var WORKER_SUBTREE_CEILINGS, ID_PART, ACTION_ID;
|
|
21950
21950
|
var init_worker_subtree = __esm({
|
|
21951
21951
|
"../../core/ts/src/worker-subtree.ts"() {
|
|
21952
|
+
"use strict";
|
|
21952
21953
|
init_worker_subtree_safe_types_generated();
|
|
21953
21954
|
WORKER_SUBTREE_CEILINGS = Object.freeze({
|
|
21954
21955
|
maxEncodedBytes: 65536,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/himalaya-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.925.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
|
|
6
6
|
"keywords": [
|
|
@@ -64,5 +64,5 @@
|
|
|
64
64
|
"artifactId": "himalaya-cli"
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
|
-
"falconPackageHash": "
|
|
67
|
+
"falconPackageHash": "f7e299bf4530163cb8143700d0c7ad83fed1966b5f6f73a47e960356"
|
|
68
68
|
}
|
|
@@ -20,7 +20,7 @@ import type { EmptyStateProps } from "@himalaya/core";
|
|
|
20
20
|
import type { AskBlock, AskPlan, Tone } from "./grammar.js";
|
|
21
21
|
import { MAX_DETAIL_FIELDS, MAX_ROWS, MAX_STATS } from "./grammar.js";
|
|
22
22
|
import {
|
|
23
|
-
DETAIL_SCREEN, ENTITY_FIELDS, ENTITY_MEDIA, IMAGE_KEY, hasField, isEntity, titleFieldFor, type QueryResult,
|
|
23
|
+
DETAIL_SCREEN, ENTITY_FIELDS, ENTITY_MEDIA, IMAGE_KEY, STRICT_ENTITIES, hasField, isEntity, titleFieldFor, type QueryResult,
|
|
24
24
|
} from "./catalog.js";
|
|
25
25
|
|
|
26
26
|
/** Typed pass-through so `tsc` checks EmptyState's prop NAMES against the schema.
|
|
@@ -256,7 +256,94 @@ function statsBlock(
|
|
|
256
256
|
.map((stat) => {
|
|
257
257
|
const label = cleanCopy(stat.label);
|
|
258
258
|
const named = queryNameOf(stat.valuePath);
|
|
259
|
-
if (named && results[named])
|
|
259
|
+
if (named && results[named]) {
|
|
260
|
+
// A ROW-INDEXED stat (`$q.x.rows.0.value`) is only honest when that row
|
|
261
|
+
// exists AND is unambiguous. Two ways it used to lie, both silent:
|
|
262
|
+
//
|
|
263
|
+
// * The query returned NOTHING. `stats` is not in DATA_KINDS, so the
|
|
264
|
+
// empty-state swap below never fires for it — the card bound to a
|
|
265
|
+
// row that is not there and the renderer painted its "—" boot
|
|
266
|
+
// placeholder for ever. Every refusal path in this worker produces
|
|
267
|
+
// an empty result, so every refusal rendered as three dashes.
|
|
268
|
+
// * The query returned MANY. `rows.0` is then whichever row the
|
|
269
|
+
// fetch happened to build first, presented as the answer. For
|
|
270
|
+
// metrics that is always the 7-day sessions row, so an unnarrowed
|
|
271
|
+
// "how did sales do over 60 days" answered with 7-day traffic.
|
|
272
|
+
//
|
|
273
|
+
// Both are dropped with a named repair instead, which the owner sees.
|
|
274
|
+
// ANCHORED to the whole path, not a substring match on `.rows.<n>.`.
|
|
275
|
+
// An unanchored test matched anywhere, so `$q.traffic.groups.0.rows.0.value`
|
|
276
|
+
// entered the row branch, `field` came out as "value", `hasField` passed,
|
|
277
|
+
// and a binding was emitted through a `groups.0.rows` path the result does
|
|
278
|
+
// not have — recreating the blank stat this guard exists to prevent.
|
|
279
|
+
// The accepted shape is exactly `$q.<query>.rows.<n>.<field>`, and the
|
|
280
|
+
// capture groups give the index and the field directly rather than being
|
|
281
|
+
// sliced back out of the string.
|
|
282
|
+
const rowRef = /^\$q\.[A-Za-z][A-Za-z0-9_-]{0,63}\.rows\.(\d+)\.([A-Za-z][A-Za-z0-9_]*)$/
|
|
283
|
+
.exec(stat.valuePath ?? "");
|
|
284
|
+
const result = results[named];
|
|
285
|
+
// MATCHES, not `rows.length`. `runQuery` computes `count` BEFORE
|
|
286
|
+
// applying `limit` and slices `rows` after, so `limit:1` made a query
|
|
287
|
+
// that matched all three windows look like a single unambiguous row —
|
|
288
|
+
// and `rows.0` then rendered the 7-day figure as the answer to a
|
|
289
|
+
// 60-day question. Judging cardinality on the post-limit array is the
|
|
290
|
+
// same bug the guard was added to close, one layer down.
|
|
291
|
+
const matches = result.count;
|
|
292
|
+
const strict = STRICT_ENTITIES.includes(result.entity);
|
|
293
|
+
if (rowRef) {
|
|
294
|
+
const wanted = Number(rowRef[1]);
|
|
295
|
+
if (matches === 0) {
|
|
296
|
+
repairs.push(`stat "${label}": that query found nothing — dropped`);
|
|
297
|
+
return { label, path: "", tone: stat.tone };
|
|
298
|
+
}
|
|
299
|
+
// ANY index, not just 0. The invariant is "no row-indexed stat over
|
|
300
|
+
// a multi-row strict result" — `rows.1` of a broad metrics query is
|
|
301
|
+
// exactly as arbitrary as `rows.0`, and scoping the guard to index 0
|
|
302
|
+
// left `limit:2` as a way back in.
|
|
303
|
+
if (strict && matches > 1) {
|
|
304
|
+
repairs.push(`stat "${label}": ${matches} rows matched, so no single figure — dropped`);
|
|
305
|
+
return { label, path: "", tone: stat.tone };
|
|
306
|
+
}
|
|
307
|
+
if (wanted >= result.rows.length) {
|
|
308
|
+
repairs.push(`stat "${label}": row ${wanted} of ${result.rows.length} does not exist — dropped`);
|
|
309
|
+
return { label, path: "", tone: stat.tone };
|
|
310
|
+
}
|
|
311
|
+
// The FIELD has to exist too. `$q.traffic.rows.0.typo` is
|
|
312
|
+
// syntactically valid, so `rewritePath` happily emits a binding for
|
|
313
|
+
// it and the renderer draws a blank where the figure belongs — a
|
|
314
|
+
// missing number that reports itself as nothing rather than as a
|
|
315
|
+
// dropped figure. The declared contract is the authority, exactly as
|
|
316
|
+
// it is for a list's `titleField`.
|
|
317
|
+
const field = rowRef[2];
|
|
318
|
+
if (!hasField(result.entity, field)) {
|
|
319
|
+
repairs.push(`stat "${label}": no field "${field}" on ${result.entity || "that query"} — dropped`);
|
|
320
|
+
return { label, path: "", tone: stat.tone };
|
|
321
|
+
}
|
|
322
|
+
} else if (strict) {
|
|
323
|
+
// NOT A ROW PATH, on a strict entity — refused, whatever shape it is.
|
|
324
|
+
//
|
|
325
|
+
// This is an ALLOWLIST, and it is deliberately the third revision of
|
|
326
|
+
// this guard rather than a fourth special case. Each earlier version
|
|
327
|
+
// blocked the bad shape it had just been shown and left the next one
|
|
328
|
+
// open: `.count` was refused by name, so `$q.traffic.value` walked
|
|
329
|
+
// straight past it — `named` resolves, there is no row reference, it
|
|
330
|
+
// does not end in `.count`, and `rewritePath` emitted a binding for a
|
|
331
|
+
// top-level field the result does not have. Another blank where the
|
|
332
|
+
// figure belongs.
|
|
333
|
+
//
|
|
334
|
+
// A strict entity keeps its figure at `rows.<n>.<field>` and nowhere
|
|
335
|
+
// else, so that is the ONLY shape accepted. `.count` keeps its own
|
|
336
|
+
// message because it is the mistake the response schema actively
|
|
337
|
+
// invites, and saying "that is a row count" is more use than "wrong
|
|
338
|
+
// shape".
|
|
339
|
+
const why = /\.count$/.test(stat.valuePath ?? "")
|
|
340
|
+
? ".count is a row count, not a measure"
|
|
341
|
+
: "a measure lives at rows.<n>.<field>, not at the top level";
|
|
342
|
+
repairs.push(`stat "${label}": ${why} — dropped`);
|
|
343
|
+
return { label, path: "", tone: stat.tone };
|
|
344
|
+
}
|
|
345
|
+
return { label, path: rewritePath(stat.valuePath), tone: stat.tone };
|
|
346
|
+
}
|
|
260
347
|
const why = named ? `unknown query "${named}"` : "malformed valuePath";
|
|
261
348
|
if (names.length === 1) {
|
|
262
349
|
repairs.push(`stat "${label}": ${why} → bound to ${names[0]}`);
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
DEMO_PRODUCTS, DEMO_SERVICES, DEMO_ORDERS, DEMO_POSTS, DEMO_EVENTS, DEMO_APPOINTMENTS, demoOrderDate, demoAppointmentWindow,
|
|
19
19
|
} from "../_shared/owner-preview-demo.js";
|
|
20
20
|
import { ENTITIES, QUERY_LIMIT, type AskQuery, type EntityName } from "./grammar.js";
|
|
21
|
-
import { compareText, formatWeekdayDateTime } from "../_shared/engine-intl.js";
|
|
21
|
+
import { compareText, formatWeekdayDateTime, formatNumber } from "../_shared/engine-intl.js";
|
|
22
22
|
|
|
23
23
|
export type Row = Record<string, string>;
|
|
24
24
|
|
|
@@ -31,6 +31,13 @@ export const ENTITY_FIELDS: Record<EntityName, string[]> = {
|
|
|
31
31
|
posts: ["id", "title", "status", "category", "published"],
|
|
32
32
|
events: ["id", "title", "date", "location", "status", "registration"],
|
|
33
33
|
appointments: ["id", "service", "client", "time", "status"],
|
|
34
|
+
// Site analytics. One row PER MEASURE, not per day: the row carries
|
|
35
|
+
// its own provenance, because the acceptance bar is that an answer "names the
|
|
36
|
+
// window it used and the measure it read" — carrying those as FIELDS makes
|
|
37
|
+
// that structural instead of something the prompt has to remember to say.
|
|
38
|
+
// `value` is the formatted figure a `stats` block binds via
|
|
39
|
+
// `$q.<name>.rows.0.value`; `amount` is the same number unformatted, for sorting.
|
|
40
|
+
metrics: ["id", "measure", "value", "amount", "window", "days"],
|
|
34
41
|
};
|
|
35
42
|
|
|
36
43
|
/**
|
|
@@ -58,6 +65,9 @@ export const ENTITY_FIELDS: Record<EntityName, string[]> = {
|
|
|
58
65
|
export const ENTITY_MEDIA: Record<EntityName, { image?: string; badge?: string }> = {
|
|
59
66
|
products: { image: "image" },
|
|
60
67
|
services: {},
|
|
68
|
+
// A measure has no image and no status. `value` is the figure itself, not a
|
|
69
|
+
// badge — styling it as one would read as a state rather than a number.
|
|
70
|
+
metrics: {},
|
|
61
71
|
orders: { badge: "payment" },
|
|
62
72
|
posts: { image: "image", badge: "status" },
|
|
63
73
|
events: { image: "image", badge: "status" },
|
|
@@ -77,14 +87,113 @@ export const DETAIL_SCREEN: Record<EntityName, string> = {
|
|
|
77
87
|
posts: "himi_owner_lite_post",
|
|
78
88
|
events: "himi_owner_lite_event",
|
|
79
89
|
appointments: "",
|
|
90
|
+
// A measure has no per-record detail screen; `openEntity` already resolves an
|
|
91
|
+
// empty value to nothing rather than a dead push.
|
|
92
|
+
metrics: "",
|
|
80
93
|
};
|
|
81
94
|
|
|
82
95
|
/** Human label per entity, used in summaries and the "open" affordance. */
|
|
83
96
|
const SINGULAR: Record<EntityName, string> = {
|
|
84
97
|
products: "product", services: "service", orders: "order",
|
|
85
98
|
posts: "post", events: "event", appointments: "appointment",
|
|
99
|
+
metrics: "measure",
|
|
86
100
|
};
|
|
87
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The measures Ask can answer for, and the windows it can answer over.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately the SAME six measures and the SAME 7/30/60 windows as
|
|
106
|
+
* `himi_owner_lite_analytics`, off the same endpoint. That is what makes "the
|
|
107
|
+
* number can be reconciled against the Analytics screen" true by construction
|
|
108
|
+
* rather than by coincidence — two engines answering one question differently
|
|
109
|
+
* is the worst outcome an analytics feature can have.
|
|
110
|
+
*
|
|
111
|
+
* No 90-day window: the endpoint retains 62 days, so it was never servable.
|
|
112
|
+
*/
|
|
113
|
+
export const METRIC_MEASURES: { key: string; api: string; label: string; money?: boolean }[] = [
|
|
114
|
+
{ key: "sessions", api: "TOTAL_SESSIONS", label: "Site sessions" },
|
|
115
|
+
{ key: "visitors", api: "TOTAL_UNIQUE_VISITORS", label: "Unique visitors" },
|
|
116
|
+
{ key: "sales", api: "TOTAL_SALES", label: "Total sales", money: true },
|
|
117
|
+
{ key: "orders", api: "TOTAL_ORDERS", label: "Total orders" },
|
|
118
|
+
{ key: "forms", api: "TOTAL_FORMS_SUBMITTED", label: "Forms submitted" },
|
|
119
|
+
{ key: "contacts", api: "CLICKS_TO_CONTACT", label: "Clicks to contact" },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
export const METRIC_WINDOWS = [7, 30, 60];
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Shape fetched measurements into metric rows. PURE, and separate from the
|
|
126
|
+
* fetch in live.ts precisely so the rule below is testable without a network.
|
|
127
|
+
*
|
|
128
|
+
* A measure the site does not report is OMITTED, never zeroed. "0 sales" and
|
|
129
|
+
* "we have no sales figure" are different answers and only one of them is true
|
|
130
|
+
* — this is the same rule the Semantic Model expresses as `missingFields`, and
|
|
131
|
+
* it is the one that keeps a silent gap from rendering as a real zero.
|
|
132
|
+
*/
|
|
133
|
+
export function metricRowsFrom(
|
|
134
|
+
windows: { days: number; data: { type?: string; total?: unknown; values?: { value?: unknown }[] }[] }[],
|
|
135
|
+
): Row[] {
|
|
136
|
+
const rows: Row[] = [];
|
|
137
|
+
for (const { days, data } of windows) {
|
|
138
|
+
for (const m of METRIC_MEASURES) {
|
|
139
|
+
const found = data.find((d) => d?.type === m.api);
|
|
140
|
+
if (!found) continue;
|
|
141
|
+
// `total` is OPTIONAL on the wire and some measures arrive as the daily
|
|
142
|
+
// series alone. The Analytics screen sums the series in that case
|
|
143
|
+
// (`total()`, himi-owner-lite-analytics/index.ts:224); omitting the
|
|
144
|
+
// measure here instead would make the two screens disagree about the
|
|
145
|
+
// same window, which is the one thing reading this endpoint rules out.
|
|
146
|
+
const hasTotal = typeof found.total === "number";
|
|
147
|
+
// At least one FINITE NUMERIC point, not merely a non-empty array. A
|
|
148
|
+
// series of entries whose `value` is absent or non-numeric summed to 0,
|
|
149
|
+
// `Number.isFinite(0)` passed, and Ask rendered 0 as though the endpoint
|
|
150
|
+
// had reported it — reintroducing the exact omission-vs-reported-zero
|
|
151
|
+
// confusion this rule exists to prevent, one layer in.
|
|
152
|
+
const points = (found.values ?? [])
|
|
153
|
+
.map((v) => v?.value)
|
|
154
|
+
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
|
|
155
|
+
if (!hasTotal && points.length === 0) continue;
|
|
156
|
+
const total = hasTotal
|
|
157
|
+
? (found.total as number)
|
|
158
|
+
: points.reduce((sum, v) => sum + v, 0);
|
|
159
|
+
// A NaN/Infinity scalar total is not a figure either.
|
|
160
|
+
if (!Number.isFinite(total)) continue;
|
|
161
|
+
rows.push({
|
|
162
|
+
id: `${m.key}-${days}`,
|
|
163
|
+
measure: m.label,
|
|
164
|
+
// Rounded EXACTLY as himi_owner_lite_analytics rounds it, so the two
|
|
165
|
+
// screens cannot disagree in the last digit about the same measure.
|
|
166
|
+
// NO currency symbol, and that is not an omission. The Analytics
|
|
167
|
+
// screen's fmtMoney is `formatNumber(round(n*100)/100)` with no symbol
|
|
168
|
+
// (himi-owner-lite-analytics/index.ts:241). Adding one here would make
|
|
169
|
+
// the two screens render the SAME number differently — breaking the
|
|
170
|
+
// reconciliation this endpoint choice exists to guarantee — and would
|
|
171
|
+
// show a euro sign to a USD, GBP or ILS site. Every other money value
|
|
172
|
+
// in this bundle comes from the API's own formattedAmount for that
|
|
173
|
+
// same reason.
|
|
174
|
+
value: m.money
|
|
175
|
+
? formatNumber(Math.round(total * 100) / 100)
|
|
176
|
+
: String(Math.round(total)),
|
|
177
|
+
// The same number unformatted, so orderBy:amount sorts numerically
|
|
178
|
+
// rather than lexically ("9" > "1,240" as text).
|
|
179
|
+
amount: String(total),
|
|
180
|
+
window: `Last ${days} days`,
|
|
181
|
+
days: String(days),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return rows;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Entities whose queries must FAIL rather than broaden (see `runQuery`).
|
|
190
|
+
*
|
|
191
|
+
* The test is "would an over-broad answer still look like an answer?" A list
|
|
192
|
+
* shows its rows and the owner can see it is wider than they asked. A single
|
|
193
|
+
* figure shows one number and nothing about it says which window produced it.
|
|
194
|
+
*/
|
|
195
|
+
export const STRICT_ENTITIES: readonly string[] = ["metrics"];
|
|
196
|
+
|
|
88
197
|
export function isEntity(name: string): name is EntityName {
|
|
89
198
|
return (ENTITIES as readonly string[]).includes(name);
|
|
90
199
|
}
|
|
@@ -93,6 +202,7 @@ export function isEntity(name: string): name is EntityName {
|
|
|
93
202
|
const TITLE_FIELD: Record<EntityName, string> = {
|
|
94
203
|
products: "name", services: "name", orders: "number",
|
|
95
204
|
posts: "title", events: "title", appointments: "service",
|
|
205
|
+
metrics: "measure",
|
|
96
206
|
};
|
|
97
207
|
|
|
98
208
|
export function titleFieldFor(entity: EntityName): string {
|
|
@@ -114,6 +224,14 @@ export function singular(entity: EntityName): string {
|
|
|
114
224
|
* (web preview, RFC 0049). On a device this is never what the owner sees. */
|
|
115
225
|
export function demoRows(entity: EntityName): Row[] {
|
|
116
226
|
switch (entity) {
|
|
227
|
+
// NO sample metrics, and this is the point rather than an omission.
|
|
228
|
+
// A sample product reads as a sample product; a sample "1,240 sessions"
|
|
229
|
+
// is indistinguishable from the owner's real traffic, and inventing one
|
|
230
|
+
// is the single failure this screen family refuses — the Analytics screen
|
|
231
|
+
// deliberately has no demo mode for the same reason. An empty result
|
|
232
|
+
// renders the honest empty block instead.
|
|
233
|
+
case "metrics":
|
|
234
|
+
return [];
|
|
117
235
|
case "products":
|
|
118
236
|
return DEMO_PRODUCTS.map((p) => ({
|
|
119
237
|
id: p.id, name: p.name, price: p.price, sku: p.sku,
|
|
@@ -194,8 +312,10 @@ function matches(row: Row, field: string, op: string, value: string): boolean {
|
|
|
194
312
|
}
|
|
195
313
|
|
|
196
314
|
/** Execute one declared query against ALREADY-LOADED rows. Unknown entity →
|
|
197
|
-
* EMPTY_RESULT
|
|
198
|
-
* ignored (fail-soft, never guess)
|
|
315
|
+
* EMPTY_RESULT. Unknown field in a where/orderBy/groupBy → that clause is
|
|
316
|
+
* ignored (fail-soft, never guess) for an ordinary entity, and the whole query
|
|
317
|
+
* returns empty for a STRICT_ENTITIES one, where broadening would read as an
|
|
318
|
+
* answer rather than as a wider list.
|
|
199
319
|
*
|
|
200
320
|
* Pure and synchronous BY DESIGN: fetching is `loadEntityRows`, one call per
|
|
201
321
|
* distinct entity per turn (`index.ts`), so three queries over `orders` cost
|
|
@@ -205,14 +325,45 @@ export function runQuery(query: AskQuery, all: Row[]): QueryResult {
|
|
|
205
325
|
if (!isEntity(query.entity)) return EMPTY_RESULT;
|
|
206
326
|
const fields = ENTITY_FIELDS[query.entity];
|
|
207
327
|
|
|
328
|
+
// STRICT ENTITIES FAIL CLOSED ON AN UNKNOWN CLAUSE.
|
|
329
|
+
//
|
|
330
|
+
// The default below is permissive: an undeclared `where` field is skipped and
|
|
331
|
+
// the query BROADENS. For a product list that is a mildly over-wide list. For
|
|
332
|
+
// a measure it is a total for the wrong window rendered as the answer — and
|
|
333
|
+
// invisibly, because nothing records the skip (`QueryResult` has no repair
|
|
334
|
+
// channel, and `repairs`/`dropped` are populated only in blocks.ts, which
|
|
335
|
+
// never sees the clause). Worse, `summary` below reads `filtered` off
|
|
336
|
+
// `count !== all.length`, so a dropped filter makes it say "12 orders"
|
|
337
|
+
// instead of "3 of 12", concealing that a filter was asked for at all.
|
|
338
|
+
//
|
|
339
|
+
// That is precisely the "confident wrong number" this screen family exists to
|
|
340
|
+
// refuse, so `metrics` refuses instead: an unsatisfiable clause yields NO
|
|
341
|
+
// rows, and the block renders its honest empty state.
|
|
342
|
+
const strict = STRICT_ENTITIES.includes(query.entity);
|
|
343
|
+
/** A strict refusal, worded so it reaches the provenance line. An empty
|
|
344
|
+
* `summary` contributes nothing to `index.ts`'s note, which would leave a
|
|
345
|
+
* refusal indistinguishable on screen from the broadening it replaced. */
|
|
346
|
+
const refuse = (why: string): QueryResult => ({
|
|
347
|
+
...EMPTY_RESULT, entity: query.entity, summary: `no ${SINGULAR[query.entity]} matched ${"—"} ${why}`,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// A clause the GRAMMAR could not parse never reaches the loop below, so it
|
|
351
|
+
// has to be refused here or the query silently runs unfiltered.
|
|
352
|
+
if (strict && query.malformed) return refuse("that filter isn't expressible");
|
|
353
|
+
|
|
208
354
|
let rows = all;
|
|
209
355
|
for (const clause of query.where ?? []) {
|
|
210
|
-
if (!fields.includes(clause.field))
|
|
356
|
+
if (!fields.includes(clause.field)) {
|
|
357
|
+
if (strict) return refuse(`no such field "${clause.field}"`);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
211
360
|
rows = rows.filter((row) => matches(row, clause.field, clause.op ?? "eq", clause.value));
|
|
212
361
|
}
|
|
213
362
|
const count = rows.length;
|
|
214
363
|
|
|
215
364
|
const order = query.orderBy;
|
|
365
|
+
if (order && strict && !fields.includes(order.field)) return refuse(`no such field "${order.field}"`);
|
|
366
|
+
if (query.groupBy && strict && !fields.includes(query.groupBy)) return refuse(`no such field "${query.groupBy}"`);
|
|
216
367
|
if (order && fields.includes(order.field)) {
|
|
217
368
|
const dir = order.direction === "asc" ? 1 : -1;
|
|
218
369
|
rows = rows.slice().sort((a, b) => {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// what doesn't hold). Robustness beats schema elegance at the model boundary.
|
|
23
23
|
|
|
24
24
|
/** Entity names the model may query. Mirrors ENTITY_FIELDS in catalog.ts. */
|
|
25
|
-
export const ENTITIES = ["products", "services", "orders", "posts", "events", "appointments"] as const;
|
|
25
|
+
export const ENTITIES = ["products", "services", "orders", "posts", "events", "appointments", "metrics"] as const;
|
|
26
26
|
export type EntityName = (typeof ENTITIES)[number];
|
|
27
27
|
|
|
28
28
|
/** The action ids a GENERATED tree may reference. Deliberately narrower than the
|
|
@@ -84,6 +84,10 @@ export interface AskWhere {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
export interface AskQuery {
|
|
87
|
+
/** A `where` segment the grammar could not parse. Fail-soft entities ignore
|
|
88
|
+
* it; STRICT ones refuse, because a filter that silently disappeared is the
|
|
89
|
+
* difference between the answer asked for and a wider one. */
|
|
90
|
+
malformed?: boolean;
|
|
87
91
|
/** Referenced by blocks as `$q.<name>.…`. */
|
|
88
92
|
name: string;
|
|
89
93
|
entity: EntityName;
|
|
@@ -118,11 +122,24 @@ export function parseQueryLine(line: unknown): AskQuery | null {
|
|
|
118
122
|
if (colon < 0) continue;
|
|
119
123
|
const key = segment.slice(0, colon).trim().toLowerCase();
|
|
120
124
|
const value = segment.slice(colon + 1).trim();
|
|
121
|
-
if (!value)
|
|
125
|
+
if (!value) {
|
|
126
|
+
// An empty value on a NARROWING key is a filter the model meant to apply
|
|
127
|
+
// and did not manage to express — `where:` with nothing after it. Skipping
|
|
128
|
+
// it silently (as this did) drops the constraint before `runQuery` can see
|
|
129
|
+
// it, which is the same hole the unparseable-segment case had: a strict
|
|
130
|
+
// entity cannot refuse what never reached it, so the query broadens.
|
|
131
|
+
if (key === "where" || key === "orderby" || key === "groupby") query.malformed = true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
122
134
|
if (key === "where") {
|
|
123
135
|
// field=value | field!=value | field~value (contains)
|
|
124
136
|
const match = value.match(/^([A-Za-z][A-Za-z0-9_]*)\s*(!=|~|=)\s*(.+)$/);
|
|
125
|
-
|
|
137
|
+
// A segment this grammar cannot express (`days>=30`, `days > 30`,
|
|
138
|
+
// `measure contains sessions`) used to vanish here, BEFORE `runQuery`
|
|
139
|
+
// could see it — so a strict entity could not refuse what it never knew
|
|
140
|
+
// was asked for, and the query ran unfiltered. Record it instead; the
|
|
141
|
+
// fail-soft entities still ignore it exactly as before.
|
|
142
|
+
if (!match) { query.malformed = true; continue; }
|
|
126
143
|
const op = match[2] === "!=" ? "ne" : match[2] === "~" ? "contains" : "eq";
|
|
127
144
|
(query.where ??= []).push({ field: match[1], op, value: match[3] });
|
|
128
145
|
} else if (key === "groupby") {
|
|
@@ -228,7 +245,14 @@ export const ASK_RESPONSE_SCHEMA: Record<string, unknown> = {
|
|
|
228
245
|
label: { type: "string" },
|
|
229
246
|
// Descriptions carry real weight in structured output — without
|
|
230
247
|
// these the model invented its own path syntax (`@queries[0]`).
|
|
231
|
-
valuePath: {
|
|
248
|
+
valuePath: {
|
|
249
|
+
type: "string",
|
|
250
|
+
description:
|
|
251
|
+
"Either $q.<queryName>.count (how many rows matched) or "
|
|
252
|
+
+ "$q.<queryName>.rows.0.<field> (a field of the single matching row). "
|
|
253
|
+
+ "For a metrics query ALWAYS use rows.0.value — count is the number of "
|
|
254
|
+
+ "matching rows, not the measurement, and is never the answer.",
|
|
255
|
+
},
|
|
232
256
|
tone: { type: "string", enum: [...TONES] },
|
|
233
257
|
},
|
|
234
258
|
},
|
|
@@ -291,7 +315,9 @@ export const ASK_SYSTEM_PROMPT = [
|
|
|
291
315
|
" (where also supports field!=value and field~value for contains). Give each a short `name`.",
|
|
292
316
|
"2. Reference query results from blocks with `$q.<name>.<field>`, where <field> is",
|
|
293
317
|
" one of: count (a number), rows (the records), groups (present only when the",
|
|
294
|
-
" query sets groupBy), summary (a short human string).",
|
|
318
|
+
" query sets groupBy), summary (a short human string). A single row's field is",
|
|
319
|
+
" `$q.<name>.rows.0.<field>` — this is how a stats block shows a measure,",
|
|
320
|
+
" because count is a ROW COUNT and is never the figure a measure question wants.",
|
|
295
321
|
"3. NEVER put record values, names, numbers or dates in your output. Those come",
|
|
296
322
|
" from the query at render time. Only labels, headings and explanatory prose",
|
|
297
323
|
" are yours to write. Inventing an order number or a customer name is a bug.",
|
|
@@ -311,6 +337,25 @@ export const ASK_SYSTEM_PROMPT = [
|
|
|
311
337
|
"- callout — the finding itself when it needs to outrank the data ('2 unpaid orders').",
|
|
312
338
|
"- note — explanation or a caveat.",
|
|
313
339
|
"",
|
|
340
|
+
"Analytics questions (traffic, visitors, sales, orders, forms, clicks to contact):",
|
|
341
|
+
"use the `metrics` entity. It holds ONE ROW PER MEASURE PER WINDOW, and the only",
|
|
342
|
+
"windows that exist are days=7, days=30 and days=60 — the endpoint retains 62 days,",
|
|
343
|
+
"so anything longer has no answer and you must say so rather than approximate it.",
|
|
344
|
+
"Select with BOTH a measure and a window, e.g.:",
|
|
345
|
+
' "traffic;metrics;where:measure=Site sessions;where:days=30;limit:1"',
|
|
346
|
+
"then bind the figure with `$q.traffic.rows.0.value`, and name the window and the",
|
|
347
|
+
"measure in your own label — `$q.traffic.rows.0.window` and `.measure` carry them,",
|
|
348
|
+
"so the owner can reconcile the number against the Analytics screen.",
|
|
349
|
+
"To compare measures, order by `amount` and never by `value`: `value` is the",
|
|
350
|
+
"display string (\"1,240\"), and sorting it numerically stops at the group",
|
|
351
|
+
"separator, so it would rank 1,240 below 9. `amount` is the same number raw.",
|
|
352
|
+
"NEVER write a date into a metrics query. `where:days=30` is relative and stays",
|
|
353
|
+
"correct when this plan is replayed tomorrow; `where:date=2026-09-21` would be",
|
|
354
|
+
"replayed as yesterday's window while the screen says the figures are live.",
|
|
355
|
+
"If the question needs a measure or a window that is not in the list above, say so",
|
|
356
|
+
"in a `note` and do not substitute a nearer one — a number for a window the owner",
|
|
357
|
+
"did not ask about is worse than telling them it is not available.",
|
|
358
|
+
"",
|
|
314
359
|
"Proposing a change: set onTap.verb on a list block when the owner would",
|
|
315
360
|
"plausibly want to act on those rows (`delete` for junk, `hide` for a product",
|
|
316
361
|
"that shouldn't be on sale). You are only SUGGESTING it — the app asks the",
|
|
@@ -26,8 +26,12 @@ import { queryServices, queryBookings, deleteService, type WixService, type WixB
|
|
|
26
26
|
import { queryPosts, queryDraftPosts } from "@himalaya/wix-blog";
|
|
27
27
|
import { queryEvents, type WixEvent } from "@himalaya/wix-events";
|
|
28
28
|
import { QUERY_LIMIT, type EntityName } from "./grammar.js";
|
|
29
|
-
import { demoRows, type Row } from "./catalog.js";
|
|
29
|
+
import { demoRows, metricRowsFrom, METRIC_MEASURES, METRIC_WINDOWS, type Row } from "./catalog.js";
|
|
30
30
|
import { formatMonthDay } from "../_shared/engine-intl.js";
|
|
31
|
+
import {
|
|
32
|
+
getSiteAnalytics, getSiteTimeZone, localDateRange,
|
|
33
|
+
type WixAnalyticsMeasurement, type WixMeasure,
|
|
34
|
+
} from "@himalaya/wix-analytics";
|
|
31
35
|
|
|
32
36
|
//
|
|
33
37
|
// One fetch per entity, mapped onto the DECLARED fields. Deliberately flatter
|
|
@@ -207,8 +211,74 @@ function bookingRow(b: WixBooking): Row {
|
|
|
207
211
|
|
|
208
212
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
209
213
|
|
|
214
|
+
/**
|
|
215
|
+
* WHY EVERY COMBINATION IS FETCHED, rather than the one the question asked for.
|
|
216
|
+
*
|
|
217
|
+
* `fetchRows` receives an entity and nothing else — the query's `where` clauses
|
|
218
|
+
* are applied CLIENT-SIDE by `runQuery` afterwards. For a product list that is
|
|
219
|
+
* harmless. For a measure it would be a disaster: the window is a SERVER-side
|
|
220
|
+
* parameter, so a `where:window=…` clause filtered client-side over rows fetched
|
|
221
|
+
* for some other window would answer the wrong period while looking right.
|
|
222
|
+
*
|
|
223
|
+
* So every (measure × window) pair is materialised — 6 × 3 = 18 rows, far under
|
|
224
|
+
* QUERY_LIMIT — and `where:days=…` then selects among rows that are each
|
|
225
|
+
* genuinely what they claim. A window we do not serve matches no row, and
|
|
226
|
+
* `metrics` being strict (STRICT_ENTITIES) makes an unknown field refuse
|
|
227
|
+
* outright rather than broaden.
|
|
228
|
+
*
|
|
229
|
+
* Shaping lives in catalog.ts (`metricRowsFrom`) so it is testable without a
|
|
230
|
+
* network; this function is only the fetch.
|
|
231
|
+
*/
|
|
232
|
+
async function metricRows(): Promise<{ rows: Row[]; failed: number[] }> {
|
|
233
|
+
// The SITE's zone on every query. The endpoint buckets by whatever zone the
|
|
234
|
+
// query names and defaults to UTC, so an owner in UTC+3 asking at 01:00 would
|
|
235
|
+
// otherwise see three hours credited to yesterday — plausibly, and silently.
|
|
236
|
+
// A FAILED lookup rejects into loadEntityRows' catch; only an ABSENT zone
|
|
237
|
+
// legitimately falls back to UTC, which is the API's own default.
|
|
238
|
+
const tz = (await getSiteTimeZone()) ?? "UTC";
|
|
239
|
+
const types = METRIC_MEASURES.map((m) => m.api as WixMeasure);
|
|
240
|
+
|
|
241
|
+
// Per-window, NOT all-or-nothing. `Promise.all` rejects whole on one failure,
|
|
242
|
+
// which sent every measure to "metrics couldn't be read" — including the
|
|
243
|
+
// window the owner actually asked about, whose request had succeeded. The
|
|
244
|
+
// Analytics screen faced the same choice for its comparison window and rode
|
|
245
|
+
// an ok-flag with the response rather than letting one failure poison the
|
|
246
|
+
// rest (himi-owner-lite-analytics/index.ts:408). Same here. A window that
|
|
247
|
+
// fails simply has no rows, so `where:days=<it>` finds nothing and the
|
|
248
|
+
// answer is refused for THAT window only.
|
|
249
|
+
const windows = await Promise.all(METRIC_WINDOWS.map(async (days) => {
|
|
250
|
+
// End-EXCLUSIVE: passing today as the end returns days-1 under an "n days"
|
|
251
|
+
// label. `localDateRange` also reports the zone it could actually honour
|
|
252
|
+
// (UTC where there is no ECMA-402), and we send THAT.
|
|
253
|
+
const { startDate, endDate, effectiveZone } = localDateRange(days, tz);
|
|
254
|
+
try {
|
|
255
|
+
const res = await getSiteAnalytics({ startDate, endDate, measurementTypes: types, timeZone: effectiveZone });
|
|
256
|
+
return { days, data: (res.data ?? []) as WixAnalyticsMeasurement[], ok: true };
|
|
257
|
+
} catch {
|
|
258
|
+
// Swallowing this to `[]` made a transient, auth or server failure
|
|
259
|
+
// indistinguishable from a site with genuinely no traffic: the rows were
|
|
260
|
+
// simply missing and the turn reported no error at all. The window is
|
|
261
|
+
// carried out as FAILED so the provenance can say which one could not be
|
|
262
|
+
// read, while the windows that succeeded still answer.
|
|
263
|
+
return { days, data: [] as WixAnalyticsMeasurement[], ok: false };
|
|
264
|
+
}
|
|
265
|
+
}));
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
rows: metricRowsFrom(windows.filter((w) => w.ok)),
|
|
269
|
+
failed: windows.filter((w) => !w.ok).map((w) => w.days),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
210
273
|
async function fetchRows(entity: EntityName): Promise<Row[]> {
|
|
211
274
|
switch (entity) {
|
|
275
|
+
// Unreachable: `loadEntityRows` branches on metrics BEFORE calling this,
|
|
276
|
+
// because a metric fetch has a second return channel (which windows
|
|
277
|
+
// failed) that `Promise<Row[]>` cannot carry. The arm stays so the switch
|
|
278
|
+
// remains exhaustive over EntityName — that exhaustiveness is what forces
|
|
279
|
+
// a new entity to be implemented rather than silently returning nothing.
|
|
280
|
+
case "metrics":
|
|
281
|
+
throw new Error("metrics rows load through loadEntityRows, not fetchRows");
|
|
212
282
|
case "orders": {
|
|
213
283
|
const res = await searchOrders({
|
|
214
284
|
search: { filter: { archived: false }, sort: [{ fieldName: "createdDate", order: "DESC" }], cursorPaging: { limit: QUERY_LIMIT } },
|
|
@@ -263,11 +333,36 @@ async function fetchRows(entity: EntityName): Promise<Row[]> {
|
|
|
263
333
|
* An empty LIVE vertical returns [] and stays empty: "you have no unpaid
|
|
264
334
|
* orders" is a correct answer, and faking rows to avoid it would be a lie. */
|
|
265
335
|
export async function loadEntityRows(entity: EntityName): Promise<{ rows: Row[]; demo: boolean; error: string }> {
|
|
266
|
-
|
|
336
|
+
// `metrics` opts out of BOTH sample arms, not just the bridge one. Returning
|
|
337
|
+
// zero rows flagged `demo:true` made the turn announce "sample data — sign in
|
|
338
|
+
// as the site owner…" while nothing had been read and no error was reported:
|
|
339
|
+
// the owner was told samples were being shown when none existed.
|
|
340
|
+
if (isOwnerPreviewDemo()) {
|
|
341
|
+
return entity === "metrics"
|
|
342
|
+
? { rows: [], demo: false, error: "analytics needs your real site" }
|
|
343
|
+
: { rows: demoRows(entity), demo: true, error: "" };
|
|
344
|
+
}
|
|
267
345
|
try {
|
|
346
|
+
if (entity === "metrics") {
|
|
347
|
+
const { rows, failed } = await metricRows();
|
|
348
|
+
return {
|
|
349
|
+
rows,
|
|
350
|
+
demo: false,
|
|
351
|
+
// Partial availability is reported, not hidden. A window that could not
|
|
352
|
+
// be read is NOT a window with no traffic, and the difference is the
|
|
353
|
+
// whole point of this entity.
|
|
354
|
+
error: failed.length
|
|
355
|
+
? `analytics for the ${failed.join(" and ")} day window${failed.length > 1 ? "s" : ""} couldn't be read`
|
|
356
|
+
: "",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
268
359
|
return { rows: await fetchRows(entity), demo: false, error: "" };
|
|
269
360
|
} catch (e) {
|
|
270
|
-
|
|
361
|
+
// `metrics` opts OUT of the sample fallback. For every other entity a
|
|
362
|
+
// labelled demo row keeps the web preview useful; for a measure it would be
|
|
363
|
+
// fabricated traffic and revenue, which is the one thing this screen family
|
|
364
|
+
// refuses. It takes the error arm below instead, exactly as a timeout does.
|
|
365
|
+
if (isBridgeUnavailable(e) && entity !== "metrics") return { rows: demoRows(entity), demo: true, error: "" };
|
|
271
366
|
return { rows: [], demo: false, error: `${entity} couldn't be read` };
|
|
272
367
|
}
|
|
273
368
|
}
|