@firedrill-tools/netsuite 0.1.2
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/LICENSE +201 -0
- package/README.md +214 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +6905 -0
- package/firedrill/concurrency-limited.scenario.json +11 -0
- package/firedrill/conformance.suite.json +21 -0
- package/firedrill/ns-analytics.drill.json +73 -0
- package/firedrill/ns-concurrency.drill.json +53 -0
- package/firedrill/ns-default-identity.drill.json +43 -0
- package/firedrill/ns-denied.drill.json +38 -0
- package/firedrill/ns-insufficient-permission.drill.json +53 -0
- package/firedrill/ns-invalid-login.drill.json +53 -0
- package/firedrill/ns-order-to-cash.drill.json +93 -0
- package/firedrill/ns-record-locked.drill.json +53 -0
- package/firedrill/ns-records.drill.json +68 -0
- package/firedrill/ns-role-ar-clerk.drill.json +58 -0
- package/firedrill/ns-role-sales-rep.drill.json +58 -0
- package/firedrill/ns-subsidiary-scope.drill.json +58 -0
- package/firedrill/ns-tight-limits.drill.json +58 -0
- package/firedrill/ns-transform-lost.drill.json +63 -0
- package/firedrill/ns-write-outage.drill.json +63 -0
- package/firedrill/record-locked.scenario.json +11 -0
- package/firedrill/tight-limits.scenario.json +17 -0
- package/firedrill/tools/netsuite/app/assets/ATTRIBUTION.md +37 -0
- package/firedrill/tools/netsuite/app/assets/fonts/LICENSE.txt +92 -0
- package/firedrill/tools/netsuite/app/assets/oracle-netsuite.svg +1 -0
- package/firedrill/tools/netsuite/app/assets/oracle.svg +1 -0
- package/firedrill/tools/netsuite/app/site/app.js +182 -0
- package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin-ext.woff2 +0 -0
- package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin.woff2 +0 -0
- package/firedrill/tools/netsuite/app/site/assets/oracle-netsuite.svg +1 -0
- package/firedrill/tools/netsuite/app/site/assets/oracle.svg +1 -0
- package/firedrill/tools/netsuite/app/site/icons.js +59 -0
- package/firedrill/tools/netsuite/app/site/index.html +68 -0
- package/firedrill/tools/netsuite/app/site/menus.js +111 -0
- package/firedrill/tools/netsuite/app/site/overlay.js +121 -0
- package/firedrill/tools/netsuite/app/site/store.js +95 -0
- package/firedrill/tools/netsuite/app/site/styles/base.css +131 -0
- package/firedrill/tools/netsuite/app/site/styles/chrome.css +183 -0
- package/firedrill/tools/netsuite/app/site/styles/pages.css +134 -0
- package/firedrill/tools/netsuite/app/site/styles/tables.css +92 -0
- package/firedrill/tools/netsuite/app/site/ui.js +119 -0
- package/firedrill/tools/netsuite/app/site/views/common.js +134 -0
- package/firedrill/tools/netsuite/app/site/views/customer-form.js +152 -0
- package/firedrill/tools/netsuite/app/site/views/customer.js +147 -0
- package/firedrill/tools/netsuite/app/site/views/customers.js +111 -0
- package/firedrill/tools/netsuite/app/site/views/home-chrome.js +75 -0
- package/firedrill/tools/netsuite/app/site/views/home-data.js +153 -0
- package/firedrill/tools/netsuite/app/site/views/home.js +162 -0
- package/firedrill/tools/netsuite/app/site/views/invoice-new.js +131 -0
- package/firedrill/tools/netsuite/app/site/views/invoice.js +232 -0
- package/firedrill/tools/netsuite/app/site/views/invoices.js +99 -0
- package/firedrill/tools/netsuite/app/site/views/items.js +95 -0
- package/firedrill/tools/netsuite/app/site/views/order-new.js +154 -0
- package/firedrill/tools/netsuite/app/site/views/order.js +214 -0
- package/firedrill/tools/netsuite/app/site/views/orders.js +93 -0
- package/firedrill/tools/netsuite/app/site/views/payments.js +65 -0
- package/firedrill/tools/netsuite/app/site/views/pickers.js +117 -0
- package/firedrill/tools/netsuite/app/site/views/subsidiaries.js +40 -0
- package/firedrill/tools/netsuite/app/site/views/suiteql.js +81 -0
- package/firedrill/tools/netsuite/behavior.mjs +13 -0
- package/firedrill/tools/netsuite/lib/body.mjs +185 -0
- package/firedrill/tools/netsuite/lib/collections.mjs +202 -0
- package/firedrill/tools/netsuite/lib/errors.mjs +91 -0
- package/firedrill/tools/netsuite/lib/handlers-meta.mjs +100 -0
- package/firedrill/tools/netsuite/lib/handlers-read.mjs +141 -0
- package/firedrill/tools/netsuite/lib/handlers-session.mjs +52 -0
- package/firedrill/tools/netsuite/lib/handlers-write.mjs +6 -0
- package/firedrill/tools/netsuite/lib/metadata.mjs +148 -0
- package/firedrill/tools/netsuite/lib/primitives.mjs +105 -0
- package/firedrill/tools/netsuite/lib/project-tran.mjs +153 -0
- package/firedrill/tools/netsuite/lib/project.mjs +178 -0
- package/firedrill/tools/netsuite/lib/q.mjs +280 -0
- package/firedrill/tools/netsuite/lib/session.mjs +188 -0
- package/firedrill/tools/netsuite/lib/suiteql-parse.mjs +382 -0
- package/firedrill/tools/netsuite/lib/suiteql-run.mjs +270 -0
- package/firedrill/tools/netsuite/lib/suiteql-select.mjs +118 -0
- package/firedrill/tools/netsuite/lib/suiteql-tables.mjs +162 -0
- package/firedrill/tools/netsuite/lib/tran.mjs +133 -0
- package/firedrill/tools/netsuite/lib/wire-decode.mjs +107 -0
- package/firedrill/tools/netsuite/lib/wire.mjs +228 -0
- package/firedrill/tools/netsuite/lib/write-customer.mjs +181 -0
- package/firedrill/tools/netsuite/lib/write-invoice.mjs +319 -0
- package/firedrill/tools/netsuite/lib/write-order.mjs +269 -0
- package/firedrill/tools/netsuite/netsuite.tool.json +4888 -0
- package/firedrill/transform-response-lost.scenario.json +11 -0
- package/firedrill/world.json +7711 -0
- package/firedrill/write-outage.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +6904 -0
- package/test/conformance.mjs +701 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Record pickers used by the transaction entry forms. Each pages through its collection as the user types,
|
|
2
|
+
// so nothing past the first page is silently hidden.
|
|
3
|
+
import { listIds, expand } from "../store.js";
|
|
4
|
+
import { el, errorBanner, lit, money, qty } from "../ui.js";
|
|
5
|
+
import { dialog } from "../overlay.js";
|
|
6
|
+
|
|
7
|
+
const PAGE = 10;
|
|
8
|
+
|
|
9
|
+
function pickerDialog({ title, label, placeholder, search, columns, renderRow }) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
let answered = false;
|
|
12
|
+
let offset = 0;
|
|
13
|
+
let term = "";
|
|
14
|
+
const body = el("tbody", {});
|
|
15
|
+
const status = el("div", { class: "muted", text: "Loading…" });
|
|
16
|
+
const inputId = `${title.replace(/\W+/g, "-").toLowerCase()}-search`;
|
|
17
|
+
const input = el("input", { type: "search", id: inputId, placeholder });
|
|
18
|
+
const previous = el("button", { type: "button", class: "btn", text: "Previous", disabled: true });
|
|
19
|
+
const next = el("button", { type: "button", class: "btn", text: "Next", disabled: true });
|
|
20
|
+
|
|
21
|
+
const choose = (record) => { answered = true; resolve(record); box.close(); };
|
|
22
|
+
|
|
23
|
+
async function load() {
|
|
24
|
+
status.textContent = "Loading…";
|
|
25
|
+
try {
|
|
26
|
+
const { rows, page } = await search(term, offset, PAGE);
|
|
27
|
+
body.replaceChildren(...(rows.length === 0
|
|
28
|
+
? [el("tr", {}, el("td", { colspan: String(columns.length), class: "muted", text: "No matching records." }))]
|
|
29
|
+
: rows.map((record) => {
|
|
30
|
+
const row = renderRow(record);
|
|
31
|
+
row.tabIndex = 0;
|
|
32
|
+
row.addEventListener("click", () => choose(record));
|
|
33
|
+
row.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); choose(record); } });
|
|
34
|
+
return row;
|
|
35
|
+
})));
|
|
36
|
+
status.textContent = page.totalResults === 0
|
|
37
|
+
? "No records"
|
|
38
|
+
: `${page.offset + 1} to ${page.offset + rows.length} of ${page.totalResults}`;
|
|
39
|
+
previous.disabled = offset === 0;
|
|
40
|
+
next.disabled = page.hasMore !== true;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
body.replaceChildren(el("tr", {}, el("td", { colspan: String(columns.length) }, errorBanner(error, "This list could not be loaded"))));
|
|
43
|
+
status.textContent = "";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let timer;
|
|
47
|
+
input.addEventListener("input", () => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
timer = setTimeout(() => { term = input.value.trim(); offset = 0; load(); }, 200);
|
|
50
|
+
});
|
|
51
|
+
previous.addEventListener("click", () => { offset = Math.max(0, offset - PAGE); load(); });
|
|
52
|
+
next.addEventListener("click", () => { offset += PAGE; load(); });
|
|
53
|
+
|
|
54
|
+
const box = dialog({
|
|
55
|
+
title,
|
|
56
|
+
wide: true,
|
|
57
|
+
body: [
|
|
58
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: inputId, text: label }), input]),
|
|
59
|
+
el("div", { class: "tablewrap" }, el("table", { class: "grid" }, [
|
|
60
|
+
el("thead", {}, el("tr", {}, columns.map((column) => el("th", { scope: "col", class: column.numeric ? "num" : "", text: column.label })))),
|
|
61
|
+
body,
|
|
62
|
+
])),
|
|
63
|
+
el("div", { class: "pager" }, [status, el("span", { class: "grow" }), previous, next]),
|
|
64
|
+
],
|
|
65
|
+
actions: [{ label: "Cancel", run: (close) => close() }],
|
|
66
|
+
onClose: () => { if (!answered) resolve(null); },
|
|
67
|
+
});
|
|
68
|
+
load();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function pickCustomer() {
|
|
73
|
+
return pickerDialog({
|
|
74
|
+
title: "Choose a Customer",
|
|
75
|
+
label: "Search customers",
|
|
76
|
+
placeholder: "Name, company or e-mail",
|
|
77
|
+
columns: [{ label: "Name" }, { label: "Subsidiary" }, { label: "Balance", numeric: true }],
|
|
78
|
+
search: async (term, offset, limit) => {
|
|
79
|
+
const parts = ["isInactive IS false"];
|
|
80
|
+
if (term !== "") {
|
|
81
|
+
const value = lit(term);
|
|
82
|
+
parts.push(`(entityId CONTAIN ${value} OR companyName CONTAIN ${value} OR email CONTAIN ${value})`);
|
|
83
|
+
}
|
|
84
|
+
const page = await listIds("customer.list", { q: parts.join(" AND "), limit, offset });
|
|
85
|
+
return { rows: await expand("customer.get", page.ids), page };
|
|
86
|
+
},
|
|
87
|
+
renderRow: (record) => el("tr", {}, [
|
|
88
|
+
el("td", { text: record.entityId ?? record.id }),
|
|
89
|
+
el("td", { text: record.subsidiary?.refName ?? "" }),
|
|
90
|
+
el("td", { class: "num", text: money(record.balance, record.currency?.refName ?? "") }),
|
|
91
|
+
]),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function pickItem() {
|
|
96
|
+
return pickerDialog({
|
|
97
|
+
title: "Choose an Item",
|
|
98
|
+
label: "Search items",
|
|
99
|
+
placeholder: "Item name or display name",
|
|
100
|
+
columns: [{ label: "Item" }, { label: "Display Name" }, { label: "Base Price", numeric: true }, { label: "On Hand", numeric: true }],
|
|
101
|
+
search: async (term, offset, limit) => {
|
|
102
|
+
const parts = ["isInactive IS false"];
|
|
103
|
+
if (term !== "") {
|
|
104
|
+
const value = lit(term);
|
|
105
|
+
parts.push(`(itemId CONTAIN ${value} OR displayName CONTAIN ${value})`);
|
|
106
|
+
}
|
|
107
|
+
const page = await listIds("inventory-item.list", { q: parts.join(" AND "), limit, offset });
|
|
108
|
+
return { rows: await expand("inventory-item.get", page.ids), page };
|
|
109
|
+
},
|
|
110
|
+
renderRow: (record) => el("tr", {}, [
|
|
111
|
+
el("td", { text: record.itemId }),
|
|
112
|
+
el("td", { text: record.displayName ?? "" }),
|
|
113
|
+
el("td", { class: "num", text: money(record.basePrice) }),
|
|
114
|
+
el("td", { class: "num", text: qty(record.quantityOnHand) }),
|
|
115
|
+
]),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Setup → Company → Subsidiaries: the read-only subsidiary hierarchy of this OneWorld account.
|
|
2
|
+
import { register } from "../store.js";
|
|
3
|
+
import { call, el, errorBanner, emptyState, mdy, skeletonRows } from "../ui.js";
|
|
4
|
+
import { listShell, setPageTitle, nsButton } from "./common.js";
|
|
5
|
+
|
|
6
|
+
const COLUMNS = [
|
|
7
|
+
{ label: "Name" }, { label: "Internal ID" }, { label: "Parent" },
|
|
8
|
+
{ label: "Country" }, { label: "Currency" }, { label: "Elimination" }, { label: "Inactive" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
register("subsidiaries", async (main) => {
|
|
12
|
+
setPageTitle("Subsidiaries");
|
|
13
|
+
const body = el("tbody", {}, skeletonRows(COLUMNS.length, 3));
|
|
14
|
+
main.replaceChildren(listShell({
|
|
15
|
+
title: "Subsidiaries",
|
|
16
|
+
subtitle: "Read-only in this Tool: the subsidiary tree is seeded by the world.",
|
|
17
|
+
actions: [nsButton("New Subsidiary"), nsButton("Customize View")],
|
|
18
|
+
columns: COLUMNS,
|
|
19
|
+
body,
|
|
20
|
+
}));
|
|
21
|
+
try {
|
|
22
|
+
const page = await call("subsidiary.list", { limit: 50, offset: 0 });
|
|
23
|
+
const rows = page.items ?? [];
|
|
24
|
+
if (rows.length === 0) {
|
|
25
|
+
body.replaceChildren(el("tr", {}, el("td", { colspan: String(COLUMNS.length) }, emptyState("No subsidiaries are visible to this role."))));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
body.replaceChildren(...rows.map((record) => el("tr", {}, [
|
|
29
|
+
el("td", {}, el("span", { text: `${record.parent ? "— " : ""}${record.name}` })),
|
|
30
|
+
el("td", { class: "tight mono", text: record.id }),
|
|
31
|
+
el("td", { text: record.parent?.refName ?? "" }),
|
|
32
|
+
el("td", { text: record.country?.refName ?? "" }),
|
|
33
|
+
el("td", { text: record.currency?.refName ?? "" }),
|
|
34
|
+
el("td", { text: record.isElimination ? "Yes" : "No" }),
|
|
35
|
+
el("td", { text: record.isInactive ? "Yes" : "No" }),
|
|
36
|
+
])));
|
|
37
|
+
} catch (error) {
|
|
38
|
+
body.replaceChildren(el("tr", {}, el("td", { colspan: String(COLUMNS.length) }, errorBanner(error, "Subsidiaries could not be loaded"))));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Analytics → SuiteQL Query Tool: a query box, a results grid with paging and the raw response body.
|
|
2
|
+
// This screen mirrors the widely used community SuiteQL Query Tool SuiteApp rather than a core NetSuite page.
|
|
3
|
+
import { can, register } from "../store.js";
|
|
4
|
+
import { call, el, errorBanner, emptyState, describe } from "../ui.js";
|
|
5
|
+
import { recordShell, setPageTitle, toolbarButton, nsButton } from "./common.js";
|
|
6
|
+
|
|
7
|
+
const SAMPLES = [
|
|
8
|
+
["Invoiced total by customer",
|
|
9
|
+
"SELECT c.entityid AS customer, COUNT(t.id) AS invoices, SUM(t.foreigntotal) AS invoiced\nFROM transaction t JOIN customer c ON c.id = t.entity\nWHERE t.type = 'CustInvc'\nGROUP BY c.entityid\nORDER BY invoiced DESC"],
|
|
10
|
+
["Customers by subsidiary", "SELECT id, entityid, BUILTIN.DF(subsidiary) AS subsidiary FROM customer ORDER BY id"],
|
|
11
|
+
["Recent transactions", "SELECT id, tranid, trandate, type, status, foreigntotal FROM transaction ORDER BY trandate DESC"],
|
|
12
|
+
];
|
|
13
|
+
const PAGE = 25;
|
|
14
|
+
|
|
15
|
+
register("suiteql", async (main) => {
|
|
16
|
+
setPageTitle("SuiteQL Query Tool");
|
|
17
|
+
if (!can("REPO_ANALYTICS")) {
|
|
18
|
+
main.replaceChildren(recordShell({
|
|
19
|
+
title: "SuiteQL Query Tool",
|
|
20
|
+
subtitle: "SuiteAnalytics Workbook permission required",
|
|
21
|
+
body: errorBanner(new Error("Your role does not have the SuiteAnalytics Workbook permission, so SuiteQL queries are refused."), "Permission Violation"),
|
|
22
|
+
}));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const editor = el("textarea", { id: "sql-text", class: "sql-editor", spellcheck: "false", value: SAMPLES[0][1] });
|
|
26
|
+
const resultHost = el("div", {}, el("p", { class: "muted", text: "Run a query to see results." }));
|
|
27
|
+
const rawHost = el("div", {});
|
|
28
|
+
let offset = 0;
|
|
29
|
+
|
|
30
|
+
async function run(nextOffset = 0) {
|
|
31
|
+
offset = nextOffset;
|
|
32
|
+
resultHost.replaceChildren(el("p", { class: "muted", text: "Running query…" }));
|
|
33
|
+
rawHost.replaceChildren();
|
|
34
|
+
try {
|
|
35
|
+
const value = await call("suiteql.query", { q: editor.value, prefer: "transient", limit: PAGE, offset });
|
|
36
|
+
const rows = value.items ?? [];
|
|
37
|
+
if (rows.length === 0) {
|
|
38
|
+
resultHost.replaceChildren(emptyState("The query returned no rows.", "SuiteQL returns every scalar as a string, as NetSuite does."));
|
|
39
|
+
} else {
|
|
40
|
+
const columns = [...new Set(rows.flatMap((row) => Object.keys(row).filter((key) => key !== "links")))];
|
|
41
|
+
resultHost.replaceChildren(
|
|
42
|
+
el("div", { class: "tablewrap" }, el("table", { class: "grid" }, [
|
|
43
|
+
el("thead", {}, el("tr", {}, columns.map((name) => el("th", { scope: "col", text: name })))),
|
|
44
|
+
el("tbody", {}, rows.map((row) => el("tr", {}, columns.map((name) => el("td", { text: row[name] === undefined || row[name] === null ? "" : String(row[name]) }))))),
|
|
45
|
+
])),
|
|
46
|
+
el("div", { class: "pager" }, [
|
|
47
|
+
el("span", { text: `${value.offset + 1} to ${value.offset + rows.length} of ${value.totalResults}` }),
|
|
48
|
+
el("span", { class: "grow" }),
|
|
49
|
+
el("button", { type: "button", class: "btn", text: "Previous", disabled: offset === 0, onclick: () => run(Math.max(0, offset - PAGE)) }),
|
|
50
|
+
el("button", { type: "button", class: "btn", text: "Next", disabled: value.hasMore !== true, onclick: () => run(offset + PAGE) }),
|
|
51
|
+
]),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
rawHost.replaceChildren(el("div", { class: "sql-out" }, el("pre", { text: JSON.stringify(value, null, 2).slice(0, 20000) })));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
resultHost.replaceChildren(errorBanner(error, "The query could not be run"));
|
|
57
|
+
rawHost.replaceChildren(el("div", { class: "sql-out" }, el("pre", { text: describe(error) })));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
main.replaceChildren(recordShell({
|
|
62
|
+
title: "SuiteQL Query Tool",
|
|
63
|
+
subtitle: "POST /services/rest/query/v1/suiteql · Prefer: transient",
|
|
64
|
+
actions: [
|
|
65
|
+
toolbarButton("Run Query", () => run(0), { kind: "primary" }),
|
|
66
|
+
toolbarButton("Clear", () => { editor.value = ""; resultHost.replaceChildren(el("p", { class: "muted", text: "Run a query to see results." })); rawHost.replaceChildren(); }),
|
|
67
|
+
nsButton("Save Query"),
|
|
68
|
+
nsButton("Query History"),
|
|
69
|
+
],
|
|
70
|
+
body: [
|
|
71
|
+
el("div", { class: "field" }, [el("label", { class: "fl", for: "sql-text", text: "SuiteQL" }), editor]),
|
|
72
|
+
el("div", { class: "btn-row" }, [
|
|
73
|
+
el("span", { class: "muted", text: "Samples:" }),
|
|
74
|
+
...SAMPLES.map(([label, text]) => el("button", { type: "button", class: "btn link", text: label, onclick: () => { editor.value = text; } })),
|
|
75
|
+
]),
|
|
76
|
+
el("section", { class: "sublist" }, [el("header", {}, el("h3", { text: "Results" })), el("div", { class: "fg-body" }, resultHost)]),
|
|
77
|
+
el("section", { class: "sublist" }, [el("header", {}, el("h3", { text: "Response body" })), rawHost]),
|
|
78
|
+
],
|
|
79
|
+
}));
|
|
80
|
+
await run(0);
|
|
81
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// NetSuite (SuiteTalk REST Web Services subset) Tool behavior: synchronous handlers over context.state plus
|
|
2
|
+
// pure provider-shaped wire codecs. Reads live in lib/handlers-read.mjs, the MCP-shaped reads, metadata and
|
|
3
|
+
// SuiteQL in lib/handlers-meta.mjs, and the nine writes in lib/handlers-write.mjs.
|
|
4
|
+
import { readOperations } from "./lib/handlers-read.mjs";
|
|
5
|
+
import { metaOperations } from "./lib/handlers-meta.mjs";
|
|
6
|
+
import { writeOperations } from "./lib/handlers-write.mjs";
|
|
7
|
+
import { sessionOperations } from "./lib/handlers-session.mjs";
|
|
8
|
+
import { routes } from "./lib/wire.mjs";
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
operations: { ...readOperations, ...metaOperations, ...writeOperations, ...sessionOperations },
|
|
12
|
+
http: routes,
|
|
13
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Wire-body validation for the write operations: typed field readers that raise NetSuite's INVALID_CONTENT,
|
|
2
|
+
// INVALID_KEY_OR_REF and INVALID_REQUEST rather than any incidental JavaScript message.
|
|
3
|
+
import { quote } from "./errors.mjs";
|
|
4
|
+
import { isInternalId, isUnsafeKey, parseDate, round4 } from "./primitives.mjs";
|
|
5
|
+
|
|
6
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
7
|
+
|
|
8
|
+
/** Turn a decoder's body-shape flag into the documented INVALID_REQUEST failure. */
|
|
9
|
+
export function checkBodyError(session, input) {
|
|
10
|
+
if (input.bodyError === undefined) return;
|
|
11
|
+
const reasons = {
|
|
12
|
+
depth: "The request body nests deeper than the supported maximum of 512 levels.",
|
|
13
|
+
prototype: "The request body carries a reserved property name (__proto__, constructor or prototype).",
|
|
14
|
+
"not-json": "The request body could not be parsed as JSON.",
|
|
15
|
+
};
|
|
16
|
+
session.fail("INVALID_REQUEST", reasons[input.bodyError] ?? reasons["not-json"]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function requestBody(session, input) {
|
|
20
|
+
checkBodyError(session, input);
|
|
21
|
+
const body = input.body;
|
|
22
|
+
if (body === undefined) return {};
|
|
23
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
24
|
+
session.fail("INVALID_CONTENT", "The request body must be a JSON object.");
|
|
25
|
+
}
|
|
26
|
+
for (const key of Object.keys(body)) {
|
|
27
|
+
if (isUnsafeKey(key)) {
|
|
28
|
+
session.fail("INVALID_REQUEST", "The request body carries a reserved property name.");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return body;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function idempotencyKey(session, input) {
|
|
35
|
+
const key = input.idempotencyKeyHeader;
|
|
36
|
+
if (key === undefined) return;
|
|
37
|
+
if (typeof key !== "string" || !UUID.test(key)) {
|
|
38
|
+
session.fail("INVALID_PARAMETER", "The X-NetSuite-Idempotency-Key header must be an RFC 4122 UUID.", {
|
|
39
|
+
errorHeader: "X-NetSuite-Idempotency-Key",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `propertyNameValidation: error` rejects any body property the record does not define. */
|
|
45
|
+
export function checkPropertyNames(session, input, body, known, prefix = "") {
|
|
46
|
+
const mode = input.propertyNameValidation ?? "warning";
|
|
47
|
+
if (mode !== "error") return;
|
|
48
|
+
for (const key of Object.keys(body)) {
|
|
49
|
+
if (!known.has(key)) {
|
|
50
|
+
session.fail("INVALID_CONTENT", `Unknown property ${quote(`${prefix}${key}`)} for this record type.`, {
|
|
51
|
+
errorPath: `${prefix}${key}`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function checkVersion(session, input, row) {
|
|
58
|
+
if (input.ifMatchVersion === undefined) return;
|
|
59
|
+
const claimed = String(input.ifMatchVersion).replaceAll('"', "").replace(/^W\//, "");
|
|
60
|
+
if (claimed !== String(row.version)) {
|
|
61
|
+
session.fail(
|
|
62
|
+
"RCRD_HAS_BEEN_CHANGED",
|
|
63
|
+
"Record has been changed. Please refresh the record and try again.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function field(session, body, name, prefix = "") {
|
|
69
|
+
return Object.hasOwn(body, name) ? body[name] : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function stringField(session, body, name, maxLength, prefix = "") {
|
|
73
|
+
const value = field(session, body, name);
|
|
74
|
+
if (value === undefined || value === null) return value === null ? null : undefined;
|
|
75
|
+
if (typeof value !== "string" || value.length > maxLength) {
|
|
76
|
+
session.fail("INVALID_CONTENT", `Invalid value for ${quote(`${prefix}${name}`)}. Expected a string of at most ${maxLength} characters.`, {
|
|
77
|
+
errorPath: `${prefix}${name}`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function numberField(session, body, name, { min = -1e12, max = 1e12, prefix = "" } = {}) {
|
|
84
|
+
const value = field(session, body, name);
|
|
85
|
+
if (value === undefined || value === null) return value === null ? null : undefined;
|
|
86
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
|
|
87
|
+
session.fail("INVALID_CONTENT", `Invalid value for ${quote(`${prefix}${name}`)}. Expected a number between ${min} and ${max}.`, {
|
|
88
|
+
errorPath: `${prefix}${name}`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return round4(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function booleanField(session, body, name, prefix = "") {
|
|
95
|
+
const value = field(session, body, name);
|
|
96
|
+
if (value === undefined || value === null) return value === null ? null : undefined;
|
|
97
|
+
if (typeof value !== "boolean") {
|
|
98
|
+
session.fail("INVALID_CONTENT", `Invalid value for ${quote(`${prefix}${name}`)}. Expected true or false.`, {
|
|
99
|
+
errorPath: `${prefix}${name}`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function dateField(session, body, name, prefix = "") {
|
|
106
|
+
const value = field(session, body, name);
|
|
107
|
+
if (value === undefined || value === null) return value === null ? null : undefined;
|
|
108
|
+
if (typeof value !== "string") {
|
|
109
|
+
session.fail("INVALID_CONTENT", `Invalid value for ${quote(`${prefix}${name}`)}. Expected a date.`, { errorPath: `${prefix}${name}` });
|
|
110
|
+
}
|
|
111
|
+
const parsed = parseDate(value);
|
|
112
|
+
if (parsed === null) {
|
|
113
|
+
session.fail("INVALID_CONTENT", `Invalid value ${quote(value)} for ${quote(`${prefix}${name}`)}. Expected M/D/YYYY or YYYY-MM-DD.`, {
|
|
114
|
+
errorPath: `${prefix}${name}`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return parsed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Read a `{ id }` reference; the id must be syntactically valid before any state lookup. */
|
|
121
|
+
export function referenceField(session, body, name, prefix = "") {
|
|
122
|
+
const value = field(session, body, name);
|
|
123
|
+
if (value === undefined || value === null) return value === null ? null : undefined;
|
|
124
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
125
|
+
session.fail("INVALID_CONTENT", `Invalid value for ${quote(`${prefix}${name}`)}. Expected an object carrying an id.`, {
|
|
126
|
+
errorPath: `${prefix}${name}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const id = Object.hasOwn(value, "id") ? value.id : undefined;
|
|
130
|
+
if (id === undefined) {
|
|
131
|
+
session.fail("INVALID_KEY_OR_REF", `The reference ${quote(`${prefix}${name}`)} must carry an id.`, { errorPath: `${prefix}${name}` });
|
|
132
|
+
}
|
|
133
|
+
const text = typeof id === "number" && Number.isSafeInteger(id) ? String(id) : id;
|
|
134
|
+
if (!isInternalId(text)) {
|
|
135
|
+
session.fail("INVALID_KEY_OR_REF", `Invalid reference key ${quote(String(id))} for ${quote(`${prefix}${name}`)}.`, {
|
|
136
|
+
errorPath: `${prefix}${name}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return text;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Read `item.items` (or `apply.items`) as a bounded array of objects. */
|
|
143
|
+
export function sublistItems(session, body, name, maxItems, prefix = "") {
|
|
144
|
+
const container = field(session, body, name);
|
|
145
|
+
if (container === undefined || container === null) return undefined;
|
|
146
|
+
if (typeof container !== "object" || Array.isArray(container)) {
|
|
147
|
+
session.fail("INVALID_CONTENT", `Invalid value for ${quote(`${prefix}${name}`)}. Expected an object carrying items.`, {
|
|
148
|
+
errorPath: `${prefix}${name}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const items = Object.hasOwn(container, "items") ? container.items : undefined;
|
|
152
|
+
if (items === undefined) return [];
|
|
153
|
+
if (!Array.isArray(items) || items.length > maxItems) {
|
|
154
|
+
session.fail("INVALID_CONTENT", `Invalid value for ${quote(`${prefix}${name}.items`)}. Expected at most ${maxItems} entries.`, {
|
|
155
|
+
errorPath: `${prefix}${name}.items`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
for (const [index, entry] of items.entries()) {
|
|
159
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
160
|
+
session.fail("INVALID_CONTENT", `Invalid entry at ${quote(`${prefix}${name}.items[${index}]`)}. Expected an object.`, {
|
|
161
|
+
errorPath: `${prefix}${name}.items[${index}]`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
for (const key of Object.keys(entry)) {
|
|
165
|
+
if (isUnsafeKey(key)) session.fail("INVALID_REQUEST", "The request body carries a reserved property name.");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return items;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Bounded list of field names for the `replace` parameter. */
|
|
172
|
+
export function replaceList(session, input, allowed) {
|
|
173
|
+
if (input.replace === undefined) return new Set();
|
|
174
|
+
if (!Array.isArray(input.replace)) {
|
|
175
|
+
session.fail("INVALID_PARAMETER", "The replace parameter must be a comma-separated list of field names.", { errorQueryParam: "replace" });
|
|
176
|
+
}
|
|
177
|
+
const names = new Set();
|
|
178
|
+
for (const name of input.replace) {
|
|
179
|
+
if (typeof name !== "string" || !allowed.has(name)) {
|
|
180
|
+
session.fail("INVALID_PARAMETER", `Field ${quote(String(name))} cannot be replaced on this record.`, { errorQueryParam: "replace" });
|
|
181
|
+
}
|
|
182
|
+
names.add(name);
|
|
183
|
+
}
|
|
184
|
+
return names;
|
|
185
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Paging, the record-collection envelope and the filterable-field tables the `q` grammar checks against.
|
|
2
|
+
import { quote } from "./errors.mjs";
|
|
3
|
+
import { jsonBytes } from "./primitives.mjs";
|
|
4
|
+
import { evaluate, parseQ, QError } from "./q.mjs";
|
|
5
|
+
import { indexById } from "./project.mjs";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_LIMIT = 1000;
|
|
8
|
+
const MAX_LIMIT = 1000;
|
|
9
|
+
|
|
10
|
+
const integerOf = (value) => {
|
|
11
|
+
if (typeof value === "number") return Number.isSafeInteger(value) ? value : null;
|
|
12
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
13
|
+
const parsed = Number(value);
|
|
14
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function booleanOf(session, value, name) {
|
|
18
|
+
if (value === undefined) return false;
|
|
19
|
+
if (typeof value === "boolean") return value;
|
|
20
|
+
if (value === "true") return true;
|
|
21
|
+
if (value === "false") return false;
|
|
22
|
+
session.fail("INVALID_PARAMETER", `Invalid value ${quote(String(value))} for the ${name} parameter. Use true or false.`, {
|
|
23
|
+
errorQueryParam: name,
|
|
24
|
+
});
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** NetSuite paging: limit 1–1000 (default 1000) and an offset that is a multiple of limit. */
|
|
29
|
+
export function paging(session, input, defaultLimit = DEFAULT_LIMIT) {
|
|
30
|
+
let limit = defaultLimit;
|
|
31
|
+
if (input.limit !== undefined) {
|
|
32
|
+
const parsed = integerOf(input.limit);
|
|
33
|
+
if (parsed === null || parsed < 1 || parsed > MAX_LIMIT) {
|
|
34
|
+
session.fail("INVALID_PARAMETER", `Invalid value ${quote(String(input.limit))} for the limit parameter. Use an integer between 1 and ${MAX_LIMIT}.`, {
|
|
35
|
+
errorQueryParam: "limit",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
limit = parsed;
|
|
39
|
+
}
|
|
40
|
+
let offset = 0;
|
|
41
|
+
if (input.offset !== undefined) {
|
|
42
|
+
const parsed = integerOf(input.offset);
|
|
43
|
+
if (parsed === null || parsed < 0) {
|
|
44
|
+
session.fail("INVALID_PARAMETER", `Invalid value ${quote(String(input.offset))} for the offset parameter. Use an integer of 0 or more.`, {
|
|
45
|
+
errorQueryParam: "offset",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
offset = parsed;
|
|
49
|
+
}
|
|
50
|
+
if (offset % limit !== 0) {
|
|
51
|
+
session.fail("INVALID_PARAMETER", `Invalid value ${quote(String(offset))} for the offset parameter. The offset must be a multiple of the limit (${limit}).`, {
|
|
52
|
+
errorQueryParam: "offset",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return { limit, offset };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const day = (value) => (typeof value === "string" ? value.slice(0, 10) : null);
|
|
59
|
+
|
|
60
|
+
export const FILTER_FIELDS = {
|
|
61
|
+
customer: {
|
|
62
|
+
id: { type: "numeric", read: (row) => Number(row.id) },
|
|
63
|
+
entityId: { type: "string", read: (row) => row.entityId },
|
|
64
|
+
companyName: { type: "string", read: (row) => row.companyName },
|
|
65
|
+
firstName: { type: "string", read: (row) => row.firstName },
|
|
66
|
+
lastName: { type: "string", read: (row) => row.lastName },
|
|
67
|
+
email: { type: "string", read: (row) => row.email },
|
|
68
|
+
phone: { type: "string", read: (row) => row.phone },
|
|
69
|
+
isPerson: { type: "boolean", read: (row) => row.isPerson },
|
|
70
|
+
isInactive: { type: "boolean", read: (row) => row.isInactive },
|
|
71
|
+
subsidiary: { type: "numeric", read: (row) => Number(row.subsidiaryId) },
|
|
72
|
+
dateCreated: { type: "date", read: (row) => day(row.dateCreated) },
|
|
73
|
+
lastModifiedDate: { type: "date", read: (row) => day(row.lastModifiedDate) },
|
|
74
|
+
},
|
|
75
|
+
salesOrder: {
|
|
76
|
+
id: { type: "numeric", read: (row) => Number(row.id) },
|
|
77
|
+
tranId: { type: "string", read: (row) => row.tranId },
|
|
78
|
+
tranDate: { type: "date", read: (row) => row.tranDate },
|
|
79
|
+
entity: { type: "numeric", read: (row) => Number(row.entityId) },
|
|
80
|
+
subsidiary: { type: "numeric", read: (row) => Number(row.subsidiaryId) },
|
|
81
|
+
status: { type: "string", read: (row) => row.status },
|
|
82
|
+
total: { type: "numeric", read: (row) => row.total },
|
|
83
|
+
memo: { type: "string", read: (row) => row.memo },
|
|
84
|
+
otherRefNum: { type: "string", read: (row) => row.otherRefNum },
|
|
85
|
+
lastModifiedDate: { type: "date", read: (row) => day(row.lastModifiedDate) },
|
|
86
|
+
},
|
|
87
|
+
invoice: {
|
|
88
|
+
id: { type: "numeric", read: (row) => Number(row.id) },
|
|
89
|
+
tranId: { type: "string", read: (row) => row.tranId },
|
|
90
|
+
tranDate: { type: "date", read: (row) => row.tranDate },
|
|
91
|
+
dueDate: { type: "date", read: (row) => row.dueDate },
|
|
92
|
+
entity: { type: "numeric", read: (row) => Number(row.entityId) },
|
|
93
|
+
subsidiary: { type: "numeric", read: (row) => Number(row.subsidiaryId) },
|
|
94
|
+
status: { type: "string", read: (row) => row.status },
|
|
95
|
+
total: { type: "numeric", read: (row) => row.total },
|
|
96
|
+
amountRemaining: { type: "numeric", read: (row) => Math.round((row.total - row.amountPaid) * 100) / 100 },
|
|
97
|
+
createdFrom: { type: "numeric", read: (row) => (row.createdFromId === null ? null : Number(row.createdFromId)) },
|
|
98
|
+
memo: { type: "string", read: (row) => row.memo },
|
|
99
|
+
otherRefNum: { type: "string", read: (row) => row.otherRefNum },
|
|
100
|
+
lastModifiedDate: { type: "date", read: (row) => day(row.lastModifiedDate) },
|
|
101
|
+
},
|
|
102
|
+
customerPayment: {
|
|
103
|
+
id: { type: "numeric", read: (row) => Number(row.id) },
|
|
104
|
+
tranId: { type: "string", read: (row) => row.tranId },
|
|
105
|
+
tranDate: { type: "date", read: (row) => row.tranDate },
|
|
106
|
+
customer: { type: "numeric", read: (row) => Number(row.entityId) },
|
|
107
|
+
payment: { type: "numeric", read: (row) => row.payment },
|
|
108
|
+
status: { type: "string", read: (row) => row.status },
|
|
109
|
+
},
|
|
110
|
+
inventoryItem: {
|
|
111
|
+
id: { type: "numeric", read: (row) => Number(row.id) },
|
|
112
|
+
itemId: { type: "string", read: (row) => row.itemId },
|
|
113
|
+
displayName: { type: "string", read: (row) => row.displayName },
|
|
114
|
+
isInactive: { type: "boolean", read: (row) => row.isInactive },
|
|
115
|
+
basePrice: { type: "numeric", read: (row) => row.basePrice },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Apply a `q` filter to state rows; an unparsable or unsupported filter is INVALID_REQUEST. */
|
|
120
|
+
export function applyFilter(session, resource, rows, q) {
|
|
121
|
+
if (q === undefined || q === null) return rows;
|
|
122
|
+
if (typeof q !== "string") {
|
|
123
|
+
session.fail("INVALID_REQUEST", "The q parameter must be a string.", { errorQueryParam: "q" });
|
|
124
|
+
}
|
|
125
|
+
const fields = FILTER_FIELDS[resource];
|
|
126
|
+
let tree;
|
|
127
|
+
try {
|
|
128
|
+
tree = parseQ(q, fields);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error instanceof QError) {
|
|
131
|
+
session.fail("INVALID_REQUEST", `Invalid search query: ${error.message}.`, { errorQueryParam: "q" });
|
|
132
|
+
}
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
return rows.filter((row) => {
|
|
137
|
+
const flat = {};
|
|
138
|
+
for (const [name, field] of Object.entries(fields)) flat[name] = field.read(row);
|
|
139
|
+
return evaluate(tree, flat);
|
|
140
|
+
});
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error instanceof QError) {
|
|
143
|
+
session.fail("INVALID_REQUEST", `Invalid search query: ${error.message}.`, { errorQueryParam: "q" });
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const pageHref = (path, limit, offset, q) =>
|
|
150
|
+
`${path}?${q === undefined || q === null ? "" : `q=${encodeURIComponent(q)}&`}limit=${limit}&offset=${offset}`;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build a NetSuite record collection. Entries are admitted one at a time and the assembled body's UTF-8 size is
|
|
154
|
+
* checked against maxPageBytes before it is returned, so an oversized single row fails rather than producing a
|
|
155
|
+
* body the framework would reject opaquely.
|
|
156
|
+
*/
|
|
157
|
+
export function collection(session, path, entries, { limit, offset, total, q }) {
|
|
158
|
+
const items = [];
|
|
159
|
+
let bytes = 220;
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
const size = jsonBytes(entry);
|
|
162
|
+
if (bytes + size > session.maxPageBytes) {
|
|
163
|
+
if (items.length === 0) {
|
|
164
|
+
session.fail(
|
|
165
|
+
"RESULT_SET_TOO_LARGE",
|
|
166
|
+
`Search error occurred: one result row is ${size} bytes, larger than the supported page size of ${session.maxPageBytes} bytes.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
bytes += size + 1;
|
|
172
|
+
items.push(entry);
|
|
173
|
+
}
|
|
174
|
+
const returned = items.length;
|
|
175
|
+
const hasMore = offset + returned < total;
|
|
176
|
+
const links = [{ rel: "self", href: pageHref(path, limit, offset, q) }];
|
|
177
|
+
if (hasMore) links.push({ rel: "next", href: pageHref(path, limit, offset + returned, q) });
|
|
178
|
+
if (offset > 0) {
|
|
179
|
+
links.push({ rel: "previous", href: pageHref(path, limit, Math.max(0, offset - limit), q) });
|
|
180
|
+
links.push({ rel: "first", href: pageHref(path, limit, 0, q) });
|
|
181
|
+
}
|
|
182
|
+
if (total > 0) {
|
|
183
|
+
const lastOffset = Math.floor(Math.max(0, total - 1) / limit) * limit;
|
|
184
|
+
if (lastOffset !== offset) links.push({ rel: "last", href: pageHref(path, limit, lastOffset, q) });
|
|
185
|
+
}
|
|
186
|
+
return { links, items, count: returned, offset, hasMore, totalResults: total };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Shared lookup maps every projection needs, built from bounded scans. */
|
|
190
|
+
export function projectionContext(session, expand = false) {
|
|
191
|
+
return {
|
|
192
|
+
expand,
|
|
193
|
+
today: session.today,
|
|
194
|
+
customers: indexById(session.rows("customers")),
|
|
195
|
+
items: indexById(session.rows("items")),
|
|
196
|
+
subsidiaries: indexById(session.rows("subsidiaries")),
|
|
197
|
+
invoices: indexById(session.rows("invoices")),
|
|
198
|
+
orders: indexById(session.rows("sales-orders")),
|
|
199
|
+
invoiceRows: session.rows("invoices"),
|
|
200
|
+
orderRows: session.rows("sales-orders"),
|
|
201
|
+
};
|
|
202
|
+
}
|