datagrok-tools 6.5.4 → 6.5.5
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/CHANGELOG.md +13 -1
- package/bin/commands/add.js +116 -1
- package/bin/commands/api.js +381 -2
- package/bin/commands/help.js +19 -2
- package/bin/commands/publish.js +22 -0
- package/bin/utils/ent-helpers.js +13 -0
- package/bin/utils/utils.js +6 -0
- package/domain-schema.schema.json +253 -0
- package/entity-template/domain-app.js +11 -0
- package/entity-template/domain-app.ts +11 -0
- package/package.json +2 -1
- package/bin/__tests__/build.test.js +0 -116
- package/bin/__tests__/build.test.ts +0 -101
- package/bin/__tests__/jira-wiki-converter.test.js +0 -101
- package/bin/__tests__/jira-wiki-converter.test.ts +0 -150
- package/bin/__tests__/node-dapi.connections.test.js +0 -120
- package/bin/__tests__/node-dapi.connections.test.ts +0 -84
- package/bin/__tests__/node-dapi.groups.test.js +0 -467
- package/bin/__tests__/node-dapi.groups.test.ts +0 -298
- package/bin/__tests__/node-dapi.integration.test.ts +0 -447
- package/bin/__tests__/node-dapi.shares.test.ts +0 -70
- package/bin/__tests__/node-dapi.users.test.ts +0 -58
- package/bin/__tests__/server-output.test.js +0 -171
- package/bin/__tests__/server-output.test.ts +0 -133
- package/bin/__tests__/server.test.js +0 -277
- package/bin/__tests__/server.test.ts +0 -197
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Datagrok-tools changelog
|
|
2
2
|
|
|
3
|
+
## 6.5.5 (WIP)
|
|
4
|
+
|
|
5
|
+
* GROK-20298: `grok api --ui` — a relation-bearing table's `<Table>Ui` handle carries `<Table>Update` as its fifth generic too (`<Table>Ui.client`, `.table()` and the `<Schema>UiDb` property), so `<table>Ui.client.update(id, {labels: [...]})` compiles instead of failing the excess-property check.
|
|
6
|
+
* GROK-20298: `grok api` — typed many-to-many relations: a table's `relations` block is validated with the server's rules (via/target declared in the same manifest, junction distinct from both sides, self-referential relations explicit, FK sides unique-or-explicit, junction `businessKey` covering both) and emitted as an expand-map entry `'<name>': {<name>?: {id: string; name: string}[]}`, a `<name>?: string[]` link set on `<Table>Insert`, and a new `<Table>Update` type (`Partial<<Table>Row>` plus the link sets) used by the transaction op union and passed as the client's fifth generic, so `update()` takes the link sets directly. Manifests without relations generate byte-identically.
|
|
7
|
+
* GROK-20298: `grok api --ui` — also emits the typed SCHEMA handle: `export function <schema>UiDb(): Promise<<Schema>UiDb>` (a typed `domains.db(...)`), and the `<Schema>UiDb` interface types each table property (`const db = await gritUiDb(); db.issues.form({values: {...}})` is compile-checked).
|
|
8
|
+
* GROK-20298: **BREAKING** — `grok api` schema clients name their table properties with the camelCase PLURAL of the table name (`<schema>Db.issues`, `.issueLabels`; same rule as domain-ui's `DomainDb`), and no longer pass `datetimeColumns` config — datetime materialization is registry-driven in `DomainTableClient` itself. Regenerate and rename call sites (`<schema>Db.issue` → `<schema>Db.issues`); transaction op `table:` values keep the declared singular names.
|
|
9
|
+
* GROK-20298: `grok add app --domain <schema>[.<table>]` — scaffolds a working browse/CRUD app over an entity-mapped domain table into an existing package: a one-expression `#app` function per table (`return (await domains.table('<schema>.<table>')).app();`, giving list/search/entity page/editing/permissions/deep links from the `@datagrok-libraries/domain-ui` defaults), the domain-ui dependency, and the typed clients for manifests the package declares. `--domain <schema>` covers every table of a schema the package declares; `--domain <path to schema.json>` copies the manifest into `databases/<schema>/` first. A fresh app package is `grok create <Name>` + this command.
|
|
10
|
+
* GROK-20298: `grok api --ui` — emits `src/generated/db-ui.ts` next to `db.ts`: a `<table>Ui` wrapper per domain table over the reflective `@datagrok-libraries/domain-ui` components, with this table's columns, expand keys and insert payloads checked at compile time. `<Table>Ui.table()` resolves the TYPED `DomainTable` handle (all four client generics), and `form`, `formDialog`, `grid`, `list`, `listView`, `app` and `entityView` are built on it; `edit`, `pick`, `row`, `query` and the typed `client` are unchanged, and the address moved from `<Table>Ui.table` to `<Table>Ui.address` to free the name for the handle. Opt-in and separate on purpose — data-only consumers keep `db.ts` free of any UI dependency; once the file exists, plain `grok api` keeps it up to date (delete it to opt out).
|
|
11
|
+
* GROK-20603: `grok api` — the generated `<schema>Db.transaction` signature is now mapped-tuple generic (`<T extends <Schema>TransactionOp[]>(ops: [...T])` with per-op result types); regenerating produces a different file, and positional `as DG.DomainUpdateResult`-style casts on transaction results become unnecessary.
|
|
12
|
+
* GROK-20602: **BREAKING** — `grok api` domain codegen v2: **datetime columns (including `created_on`/`updated_on`) are now typed as dayjs `Dayjs`** in `<Table>Row` (`Dayjs | string` on inserts) and generated clients pass `datetimeColumns` so JSON reads materialize dayjs objects — code that treated these fields as strings no longer compiles (untyped `table('s.t')` clients are unchanged). Also: `choices` columns emit named literal-union aliases used in Row/Insert; `<Table>Column` unions and NEW `<Table>Expand` maps are threaded through the client generics (filters/columns/expand keys compile-checked); a typed `<Schema>TransactionOp` union powers `<schema>Db.transaction`; the client map now uses LAZY getters — importing db.ts no longer touches `grok.dapi` at import time. Regenerate with `grok api` and fix datetime call sites. Migration note: choices-alias names are derived as `<Table><Column>`; when two tables' aliases collide with different value sets, the LATER table (manifest order) gets a `<Table>`-suffixed name — reordering manifest tables can therefore rename such aliases; keep table order stable or update imports after reordering.
|
|
13
|
+
* GROK-20319: `grok api` — generated domain clients now pass the `<Table>Insert` interface as the second `DG.DomainTableClient` generic, so `insert()` enforces required columns at compile time (e.g. `insert({})` no longer compiles); over-long client lines wrap before the type.
|
|
14
|
+
* GROK-20317: `grok api` — typed domain-table clients: for packages that declare `databases/<schema>/schema.json` manifests, generates `src/generated/db.ts` with per-table `<Table>Row`/`<Table>Insert` interfaces, `<Table>Column` name unions, and a per-schema `<schema>Db` client map over `grok.dapi.domains` (manifests are validated against `domain-schema.schema.json` first; packages without manifests are untouched).
|
|
3
15
|
## 6.5.4 (2026-08-07)
|
|
4
16
|
|
|
5
17
|
* func-gen-plugin — `#meta.comparison` in an `.ivp` model now lands as the `comparison` option on the generated dataframe output (run comparison reads index/split/mode/units defaults from there) instead of function-level meta.
|
|
@@ -16,7 +28,7 @@
|
|
|
16
28
|
|
|
17
29
|
* `grok test` — the Node-pass report now merges into the browser report by column name. The line-wise merge assumed identical column order, so node rows landed misaligned (string values in the integer `ms` column) and the CI test-report upload failed with `invalid input syntax for type integer`.
|
|
18
30
|
|
|
19
|
-
## 6.5.
|
|
31
|
+
## 6.5.5 (WIP)
|
|
20
32
|
|
|
21
33
|
* `grok test` — added a Node (browserless) pass: tests annotated `{node: true}` run headless under the js-api Node runtime before the browser launches; the browser pass excludes them and is skipped entirely when nothing browser-only matches. New flags: `--skip-node`, `--node-only`. Packages opt in by exporting `testNode()` from `package-test.ts`; others keep the previous behavior.
|
|
22
34
|
|
package/bin/commands/add.js
CHANGED
|
@@ -10,12 +10,17 @@ var _path = _interopRequireDefault(require("path"));
|
|
|
10
10
|
var _entHelpers = require("../utils/ent-helpers");
|
|
11
11
|
var utils = _interopRequireWildcard(require("../utils/utils"));
|
|
12
12
|
var color = _interopRequireWildcard(require("../utils/color-utils"));
|
|
13
|
+
var _api = require("./api");
|
|
13
14
|
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
15
|
+
/** The domain-ui library version a scaffolded domain app depends on. */
|
|
16
|
+
const domainUiDependency = '^0.1.0';
|
|
14
17
|
function add(args) {
|
|
15
|
-
|
|
18
|
+
// `--domain` is the only option any `add` entity takes (`grok add app --domain`).
|
|
19
|
+
const nOptions = Object.keys(args).length - 1 - (args.domain === undefined ? 0 : 1);
|
|
16
20
|
const nArgs = args['_'].length;
|
|
17
21
|
if (nArgs < 2 || nArgs > 5 || nOptions > 0) return false;
|
|
18
22
|
const entity = args['_'][1];
|
|
23
|
+
if (args.domain !== undefined && entity !== 'app') return color.error('`--domain` applies to `grok add app` only');
|
|
19
24
|
const curDir = process.cwd();
|
|
20
25
|
const curFolder = _path.default.basename(curDir);
|
|
21
26
|
const srcDir = _path.default.join(curDir, 'src');
|
|
@@ -60,6 +65,112 @@ function add(args) {
|
|
|
60
65
|
_fs.default.writeFileSync(packageEntry, contents, 'utf8');
|
|
61
66
|
}
|
|
62
67
|
}
|
|
68
|
+
|
|
69
|
+
/** The manifest at [manifestPath], or null when it is missing or unreadable. */
|
|
70
|
+
function readManifest(manifestPath) {
|
|
71
|
+
if (!_fs.default.existsSync(manifestPath)) return null;
|
|
72
|
+
try {
|
|
73
|
+
const manifest = JSON.parse(_fs.default.readFileSync(manifestPath, 'utf8'));
|
|
74
|
+
if (typeof manifest?.name !== 'string' || manifest?.tables == null) {
|
|
75
|
+
color.error(`${manifestPath} is not a domain schema manifest (no \`name\` / \`tables\`)`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return manifest;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
color.error(`Error while reading ${manifestPath}:`);
|
|
81
|
+
console.error(error);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Copies a schema manifest into `databases/<schema>/` (where `grok api` and the
|
|
87
|
+
* publisher look for it); returns its schema name and table names. */
|
|
88
|
+
function copyManifest(manifestPath) {
|
|
89
|
+
const manifest = readManifest(_path.default.resolve(curDir, manifestPath));
|
|
90
|
+
if (manifest == null) return null;
|
|
91
|
+
const target = _path.default.join(curDir, 'databases', manifest.name, 'schema.json');
|
|
92
|
+
if (_path.default.resolve(curDir, manifestPath) !== target) {
|
|
93
|
+
_fs.default.mkdirSync(_path.default.dirname(target), {
|
|
94
|
+
recursive: true
|
|
95
|
+
});
|
|
96
|
+
_fs.default.copyFileSync(_path.default.resolve(curDir, manifestPath), target);
|
|
97
|
+
console.log(`Copied the schema manifest to databases/${manifest.name}/schema.json`);
|
|
98
|
+
}
|
|
99
|
+
return [manifest.name, Object.keys(manifest.tables)];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Adds `import {domains} from '@datagrok-libraries/domain-ui';` to the package
|
|
103
|
+
* entry file, after its existing imports (which webpack's externals depend on). */
|
|
104
|
+
function addDomainUiImport() {
|
|
105
|
+
const contents = _fs.default.readFileSync(packageEntry, 'utf8');
|
|
106
|
+
if (contents.includes('@datagrok-libraries/domain-ui')) return;
|
|
107
|
+
const eol = contents.includes('\r\n') ? '\r\n' : '\n';
|
|
108
|
+
const line = `import {domains} from '@datagrok-libraries/domain-ui';`;
|
|
109
|
+
const lines = contents.split(/\r?\n/);
|
|
110
|
+
let last = -1;
|
|
111
|
+
for (let i = 0; i < lines.length; i++) if (/^(import .*|export .* from .*);\s*$/.test(lines[i])) last = i;
|
|
112
|
+
lines.splice(last + 1, 0, line);
|
|
113
|
+
_fs.default.writeFileSync(packageEntry, lines.join(eol), 'utf8');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `grok add app [name] --domain <schema>[.<table>] | <schema.json path>` — a working
|
|
117
|
+
* browse/CRUD app per table, from the `domain-ui` defaults alone. */
|
|
118
|
+
function addDomainApp(domainArg, appName) {
|
|
119
|
+
if (typeof domainArg !== 'string' || domainArg === '') return color.error('`--domain` needs `<schema>` or `<schema>.<table>`, ' + 'or a path to a schema.json manifest');
|
|
120
|
+
let schema;
|
|
121
|
+
let tables;
|
|
122
|
+
if (/\.json$/i.test(domainArg)) {
|
|
123
|
+
const copied = copyManifest(domainArg);
|
|
124
|
+
if (copied == null) return false;
|
|
125
|
+
[schema, tables] = copied;
|
|
126
|
+
} else {
|
|
127
|
+
if (!/^[A-Za-z_]\w*(\.[A-Za-z_]\w*)?$/.test(domainArg)) return color.error(`Malformed domain address: ${domainArg}. ` + 'Use `<schema>`, `<schema>.<table>`, or a path to a schema.json manifest');
|
|
128
|
+
const dot = domainArg.indexOf('.');
|
|
129
|
+
schema = dot === -1 ? domainArg : domainArg.slice(0, dot);
|
|
130
|
+
if (dot !== -1) tables = [domainArg.slice(dot + 1)];else {
|
|
131
|
+
// Table names of a whole schema are only known offline from its manifest.
|
|
132
|
+
const manifest = readManifest(_path.default.join(curDir, 'databases', schema, 'schema.json'));
|
|
133
|
+
if (manifest == null) return color.error(`No \`databases/${schema}/schema.json\` in this package: name a table ` + `(\`--domain ${schema}.<table>\`) or pass the path to the schema manifest`);
|
|
134
|
+
tables = Object.keys(manifest.tables);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (tables.length === 0) return color.error(`Schema \`${schema}\` declares no tables`);
|
|
138
|
+
if (appName != null && tables.length > 1) return color.error(`\`--domain ${domainArg}\` covers ${tables.length} tables — ` + 'name a single table to name its app');
|
|
139
|
+
createPackageEntryFile();
|
|
140
|
+
const template = _fs.default.readFileSync(_path.default.join(templateDir, 'entity-template', 'domain-app' + ext), 'utf8');
|
|
141
|
+
const entry = _fs.default.readFileSync(packageEntry, 'utf8');
|
|
142
|
+
const added = [];
|
|
143
|
+
const addedTables = [];
|
|
144
|
+
for (const table of tables) {
|
|
145
|
+
// No 'App' postfix — `grok check` asks apps to drop it (check.ts:350-356).
|
|
146
|
+
const funcName = appName ?? utils.snakeToCamelCase(table);
|
|
147
|
+
if (!validateName(funcName)) return false;
|
|
148
|
+
if (new RegExp(`function\\s+${funcName}\\s*\\(`).test(entry)) {
|
|
149
|
+
color.warn(`${funcName} is already declared in ${_path.default.relative(curDir, packageEntry)} — skipped`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
_fs.default.appendFileSync(packageEntry, utils.replacers['DOMAIN_TABLE'](insertName(funcName, template), `${schema}.${table}`));
|
|
153
|
+
added.push(funcName);
|
|
154
|
+
addedTables.push(`${schema}.${table}`);
|
|
155
|
+
}
|
|
156
|
+
if (added.length === 0) return true;
|
|
157
|
+
addDomainUiImport();
|
|
158
|
+
const packageObj = JSON.parse(_fs.default.readFileSync(packagePath, 'utf8'));
|
|
159
|
+
packageObj.dependencies = packageObj.dependencies ?? {};
|
|
160
|
+
if (packageObj.dependencies['@datagrok-libraries/domain-ui'] === undefined) {
|
|
161
|
+
packageObj.dependencies['@datagrok-libraries/domain-ui'] = domainUiDependency;
|
|
162
|
+
_fs.default.writeFileSync(packagePath, JSON.stringify(packageObj, null, 2), 'utf8');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Manifests in the package get typed clients AND the typed UI wrappers the app
|
|
166
|
+
// code can switch to; a schema deployed by another package has none, and the
|
|
167
|
+
// reflective app above needs none.
|
|
168
|
+
if (!(0, _api.generateDomainClients)(curDir, {
|
|
169
|
+
ui: true
|
|
170
|
+
})) return false;
|
|
171
|
+
console.log(_entHelpers.help.domainApp(added, addedTables));
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
63
174
|
let name;
|
|
64
175
|
let tag;
|
|
65
176
|
let contents;
|
|
@@ -104,6 +215,10 @@ function add(args) {
|
|
|
104
215
|
console.log(_entHelpers.help.script(name, curFolder));
|
|
105
216
|
break;
|
|
106
217
|
case 'app':
|
|
218
|
+
if (args.domain !== undefined) {
|
|
219
|
+
if (nArgs > 3) return false;
|
|
220
|
+
return addDomainApp(args.domain, nArgs === 3 ? args['_'][2] : null);
|
|
221
|
+
}
|
|
107
222
|
if (nArgs !== 3) return false;
|
|
108
223
|
|
|
109
224
|
// App name check
|
package/bin/commands/api.js
CHANGED
|
@@ -5,16 +5,56 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
value: true
|
|
6
6
|
});
|
|
7
7
|
exports.api = api;
|
|
8
|
+
exports.generateDomainClients = generateDomainClients;
|
|
8
9
|
var _fs = _interopRequireDefault(require("fs"));
|
|
9
10
|
var _path = _interopRequireDefault(require("path"));
|
|
10
11
|
var _ignoreWalk = _interopRequireDefault(require("ignore-walk"));
|
|
12
|
+
var _ajv = _interopRequireDefault(require("ajv"));
|
|
11
13
|
var utils = _interopRequireWildcard(require("../utils/utils"));
|
|
12
14
|
var color = _interopRequireWildcard(require("../utils/color-utils"));
|
|
13
15
|
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
14
16
|
const annotationForApiFile = `/**\nThis file is auto-generated by the grok api command.\nIf you notice any changes, please push them to the repository.\nDo not edit this file manually.\n*/\n`;
|
|
17
|
+
const annotationForDbFile = `/**\nThis file is auto-generated by the grok api command from databases/<schema>/schema.json manifests.\nIf you notice any changes, please push them to the repository.\nDo not edit this file manually.\n*/\n`;
|
|
18
|
+
const annotationForDbUiFile = `/**\nThis file is auto-generated by the grok api command (--ui) from databases/<schema>/schema.json manifests.\nIt is regenerated on every \`grok api\` run as long as it exists; delete it to opt out.\nIf you notice any changes, please push them to the repository.\nDo not edit this file manually.\n*/\n`;
|
|
15
19
|
const sep = '\n';
|
|
16
20
|
const packageFuncDirs = ['package.ts', 'package.g.ts'];
|
|
17
21
|
const apiFile = 'package-api.ts';
|
|
22
|
+
const dbFile = 'db.ts';
|
|
23
|
+
const dbUiFile = 'db-ui.ts';
|
|
24
|
+
/** What `db-ui.ts` imports from `@datagrok-libraries/domain-ui`. */
|
|
25
|
+
const domainUiImports = ['domainHandler', 'domains', 'DomainAppView', 'DomainAppViewOptions', 'DomainDb', 'DomainDialogOptions', 'DomainEntityAppView', 'DomainEntityAppViewOptions', 'DomainForm', 'DomainFormOptions', 'DomainGrid', 'DomainGridOptions', 'DomainTable', 'EntityListOptions', 'EntityListWidget'];
|
|
26
|
+
/** {@link DomainDb} members a table's camelCase property name must not shadow. */
|
|
27
|
+
const domainDbReservedProps = ['name', 'tables', 'table', 'acquire'];
|
|
28
|
+
|
|
29
|
+
/** Naive English plural of a (snake_case) table name — `issue` → `issues`, `box` →
|
|
30
|
+
* `boxes`, `category` → `categories`. MUST match domain-ui's `pluralizeTableName`:
|
|
31
|
+
* the runtime `DomainDb` assigns its per-table properties with the same rule, so the
|
|
32
|
+
* generated typed interfaces bind to it. */
|
|
33
|
+
function pluralizeTableName(name) {
|
|
34
|
+
if (/(s|x|z|ch|sh)$/.test(name)) return name + 'es';
|
|
35
|
+
if (/[^aeiou_]y$/.test(name)) return name.slice(0, -1) + 'ies';
|
|
36
|
+
return name + 's';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The camelCase plural property name a table gets on the schema client and the
|
|
40
|
+
* schema UI handle (`issue_label` → `issueLabels`). */
|
|
41
|
+
function tableProp(tableName) {
|
|
42
|
+
return utils.snakeToCamelCase(pluralizeTableName(tableName), false);
|
|
43
|
+
}
|
|
44
|
+
const domainSchemaPath = _path.default.join(_path.default.dirname(_path.default.dirname(__dirname)), 'domain-schema.schema.json');
|
|
45
|
+
const domainSystemColumns = [['id', 'string'], ['version', 'number'], ['created_on', 'Dayjs'], ['updated_on', 'Dayjs'], ['author_id', 'string']];
|
|
46
|
+
const domainTypeMap = {
|
|
47
|
+
string: 'string',
|
|
48
|
+
int: 'number',
|
|
49
|
+
float: 'number',
|
|
50
|
+
bool: 'boolean',
|
|
51
|
+
datetime: 'Dayjs',
|
|
52
|
+
string_list: 'string[]',
|
|
53
|
+
ref: 'string',
|
|
54
|
+
user: 'string',
|
|
55
|
+
group: 'string',
|
|
56
|
+
file: 'string'
|
|
57
|
+
};
|
|
18
58
|
function normEol(s) {
|
|
19
59
|
return s.replace(/\r\n/g, '\n');
|
|
20
60
|
}
|
|
@@ -130,13 +170,350 @@ function checkNameColision(name) {
|
|
|
130
170
|
if (names.has(name)) console.log('There is collision in name ' + name);
|
|
131
171
|
names.add(name);
|
|
132
172
|
}
|
|
173
|
+
|
|
174
|
+
/** Generates `src/generated/db.ts` with typed clients for the package's domain schemas
|
|
175
|
+
* (`databases/<schema>/schema.json` manifests), and — with [options].ui, or whenever the
|
|
176
|
+
* file is already there — `src/generated/db-ui.ts` with the typed UI sugar over
|
|
177
|
+
* `@datagrok-libraries/domain-ui`. No-op for packages without manifests. */
|
|
178
|
+
function generateDomainClients(packageDir = curDir, options) {
|
|
179
|
+
const databasesDir = _path.default.join(packageDir, 'databases');
|
|
180
|
+
if (!_fs.default.existsSync(databasesDir)) return true;
|
|
181
|
+
const manifestPaths = _fs.default.readdirSync(databasesDir).map(dir => _path.default.join(databasesDir, dir, 'schema.json')).filter(p => _fs.default.existsSync(p));
|
|
182
|
+
if (manifestPaths.length === 0) return true;
|
|
183
|
+
const validateManifest = new _ajv.default().compile(JSON.parse(_fs.default.readFileSync(domainSchemaPath, 'utf8')));
|
|
184
|
+
const genDir = _path.default.join(packageDir, 'src', 'generated');
|
|
185
|
+
// The `--ui` opt-in persists by presence: once emitted, the file is kept up to date by
|
|
186
|
+
// every plain `grok api` run, so a package's build script needs no extra flag.
|
|
187
|
+
const ui = (options?.ui ?? false) || _fs.default.existsSync(_path.default.join(genDir, dbUiFile));
|
|
188
|
+
const parts = [];
|
|
189
|
+
const uiParts = [];
|
|
190
|
+
const dbImports = [];
|
|
191
|
+
const emittedTypes = new Set();
|
|
192
|
+
for (const manifestPath of manifestPaths) {
|
|
193
|
+
const relPath = _path.default.relative(packageDir, manifestPath);
|
|
194
|
+
let manifest;
|
|
195
|
+
try {
|
|
196
|
+
manifest = JSON.parse(_fs.default.readFileSync(manifestPath, 'utf8'));
|
|
197
|
+
} catch (x) {
|
|
198
|
+
color.error(`Failed to parse ${relPath}: ${x.message}`);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if (!validateManifest(manifest)) {
|
|
202
|
+
for (const err of validateManifest.errors ?? []) color.error(`${relPath}: ${err.instancePath || '/'} ${err.message}`);
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
const code = generateDomainSchemaCode(manifest, relPath, emittedTypes);
|
|
206
|
+
if (code == null) return false;
|
|
207
|
+
parts.push(code);
|
|
208
|
+
if (ui) uiParts.push(generateDomainUiCode(manifest, dbImports));
|
|
209
|
+
}
|
|
210
|
+
_fs.default.mkdirSync(genDir, {
|
|
211
|
+
recursive: true
|
|
212
|
+
});
|
|
213
|
+
const content = annotationForDbFile + utils.dgImports + `import type {Dayjs} from 'dayjs';${sep}` + sep + parts.join(sep);
|
|
214
|
+
_fs.default.writeFileSync(_path.default.join(genDir, dbFile), normEol(content).replace(/\n/g, '\r\n'), 'utf8');
|
|
215
|
+
color.log(`Successfully generated file src/generated/${dbFile}${sep}`, 'success');
|
|
216
|
+
if (ui) {
|
|
217
|
+
const uiContent = annotationForDbUiFile + `import * as DG from 'datagrok-api/dg';${sep}` + wrapTokens(`import {`, domainUiImports, ', ', `} from '@datagrok-libraries/domain-ui';`) + sep + wrapTokens(`import {`, dbImports, ', ', `} from './${dbFile.replace(/\.ts$/, '')}';`) + sep + sep + uiParts.join(sep);
|
|
218
|
+
_fs.default.writeFileSync(_path.default.join(genDir, dbUiFile), normEol(uiContent).replace(/\n/g, '\r\n'), 'utf8');
|
|
219
|
+
color.log(`Successfully generated file src/generated/${dbUiFile}${sep}`, 'success');
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A resolved many-to-many relation of one table: the expand key and the target
|
|
225
|
+
* table whose row ids its write-side link set carries. */
|
|
226
|
+
|
|
227
|
+
/** Emits choices aliases, row/insert interfaces, column-name unions, expand maps, the
|
|
228
|
+
* `<Schema>TransactionOp` union, and the lazy `<schema>Db` clients for one manifest.
|
|
229
|
+
* Returns null on a semantic error (reported to the console). */
|
|
230
|
+
function generateDomainSchemaCode(manifest, manifestPath, emittedTypes) {
|
|
231
|
+
const decls = [];
|
|
232
|
+
const systemColumnNames = new Set(domainSystemColumns.map(([name]) => name));
|
|
233
|
+
const tableNames = Object.keys(manifest.tables);
|
|
234
|
+
const tableColumns = {};
|
|
235
|
+
// Choices aliases are deduplicated by name: identical value sets share the first alias,
|
|
236
|
+
// different sets fall back to a `<alias><PascalTable>` name (deterministic).
|
|
237
|
+
const choicesAliases = {};
|
|
238
|
+
const choicesAlias = (tableName, columnName, values) => {
|
|
239
|
+
const valueSet = values.map(v => `'${v}'`).join(' | ');
|
|
240
|
+
let alias = utils.snakeToCamelCase(tableName) + utils.snakeToCamelCase(columnName);
|
|
241
|
+
if (choicesAliases[alias] !== undefined && choicesAliases[alias] !== valueSet) alias += utils.snakeToCamelCase(tableName);
|
|
242
|
+
if (choicesAliases[alias] !== undefined && choicesAliases[alias] !== valueSet) {
|
|
243
|
+
color.error(`${manifestPath}: cannot derive a unique choices alias for '${tableName}.${columnName}'`);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
if (choicesAliases[alias] === undefined) {
|
|
247
|
+
choicesAliases[alias] = valueSet;
|
|
248
|
+
decls.push(wrapTokens(`export type ${alias} = `, values.map(v => `'${v}'`), ' | ', ';'));
|
|
249
|
+
}
|
|
250
|
+
return alias;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Pass 1: resolve every table's full column list (relational + property-schema columns).
|
|
254
|
+
for (const tableName of tableNames) {
|
|
255
|
+
const table = manifest.tables[tableName];
|
|
256
|
+
const typeName = utils.snakeToCamelCase(tableName);
|
|
257
|
+
if (emittedTypes.has(typeName)) {
|
|
258
|
+
color.error(`${manifestPath}: table '${tableName}' emits interface '${typeName}Row' ` + `already generated for another table (table names must be unique across the package's manifests)`);
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
emittedTypes.add(typeName);
|
|
262
|
+
const columns = [];
|
|
263
|
+
const columnNames = new Set(systemColumnNames);
|
|
264
|
+
const addColumn = (name, column) => {
|
|
265
|
+
if (columnNames.has(name)) {
|
|
266
|
+
color.error(systemColumnNames.has(name) ? `${manifestPath}: table '${tableName}' column '${name}' collides with a generated system column` : `${manifestPath}: table '${tableName}' declares duplicate column '${name}'`);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
columnNames.add(name);
|
|
270
|
+
let tsType = domainTypeMap[column.type];
|
|
271
|
+
if (Array.isArray(column.choices) && column.choices.length > 0) {
|
|
272
|
+
const alias = choicesAlias(tableName, name, column.choices);
|
|
273
|
+
if (alias == null) return false;
|
|
274
|
+
tsType = alias;
|
|
275
|
+
}
|
|
276
|
+
columns.push({
|
|
277
|
+
name: name,
|
|
278
|
+
rawType: column.type,
|
|
279
|
+
tsType: tsType,
|
|
280
|
+
insertType: column.type === 'datetime' ? 'Dayjs | string' : tsType,
|
|
281
|
+
required: column.required === true,
|
|
282
|
+
ref: column.type === 'ref' && manifest.tables[column.ref] != null ? column.ref : undefined
|
|
283
|
+
});
|
|
284
|
+
return true;
|
|
285
|
+
};
|
|
286
|
+
for (const columnName of Object.keys(table.columns)) if (!addColumn(columnName, table.columns[columnName])) return null;
|
|
287
|
+
for (const schemaName of table.schemas ?? []) {
|
|
288
|
+
const props = manifest.propertySchemas?.[schemaName];
|
|
289
|
+
if (props == null) {
|
|
290
|
+
color.error(`${manifestPath}: table '${tableName}' references unknown property schema '${schemaName}'`);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
for (const propName of Object.keys(props)) if (!addColumn(propName, props[propName])) return null;
|
|
294
|
+
}
|
|
295
|
+
tableColumns[tableName] = columns;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Pass 1.5: resolve declared many-to-many relations. Same rules the server's
|
|
299
|
+
// manifest parser applies (manifest.dart 'relations'), so a manifest that
|
|
300
|
+
// generates here is one that deploys: via/target declared in this manifest,
|
|
301
|
+
// via distinct from owner and target, self-referential relations explicit,
|
|
302
|
+
// FK sides unique-or-explicit, and a junction business key covering both —
|
|
303
|
+
// that key is what keeps re-linking the same pair idempotent.
|
|
304
|
+
const tableRelations = {};
|
|
305
|
+
for (const tableName of tableNames) {
|
|
306
|
+
const relations = [];
|
|
307
|
+
tableRelations[tableName] = relations;
|
|
308
|
+
const declared = manifest.tables[tableName].relations;
|
|
309
|
+
if (declared == null) continue;
|
|
310
|
+
const ownColumns = new Set(tableColumns[tableName].map(c => c.name));
|
|
311
|
+
for (const name of Object.keys(declared)) {
|
|
312
|
+
const r = declared[name];
|
|
313
|
+
const where = `${manifestPath}: relation '${tableName}.${name}'`;
|
|
314
|
+
if (ownColumns.has(name) || systemColumnNames.has(name)) {
|
|
315
|
+
color.error(`${where} collides with a column of '${tableName}' — relations and columns ` + `share one expand/filter namespace`);
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
if (manifest.tables[r.via] == null) {
|
|
319
|
+
color.error(`${where}: junction table '${r.via}' is not declared in this manifest`);
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
if (manifest.tables[r.target] == null) {
|
|
323
|
+
color.error(`${where}: target table '${r.target}' is not declared in this manifest`);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
if (r.via === tableName || r.via === r.target) {
|
|
327
|
+
color.error(`${where}: junction table '${r.via}' must differ from the owner and the target table`);
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
if (r.target === tableName && (r.viaSelf == null || r.viaTarget == null)) {
|
|
331
|
+
color.error(`${where}: a self-referential relation must name both 'viaSelf' and 'viaTarget'`);
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
// One side of the junction: the declared column when explicit, otherwise
|
|
335
|
+
// the single ref column of `via` pointing at `to`.
|
|
336
|
+
const resolveSide = (key, explicit, to) => {
|
|
337
|
+
const candidates = tableColumns[r.via].filter(c => c.ref === to).map(c => c.name);
|
|
338
|
+
if (explicit != null) {
|
|
339
|
+
if (candidates.includes(explicit)) return explicit;
|
|
340
|
+
color.error(`${where}: '${explicit}' is not a ref column of junction table '${r.via}' ` + `targeting '${to}'`);
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
if (candidates.length === 0) {
|
|
344
|
+
color.error(`${where}: junction table '${r.via}' has no ref column targeting '${to}'`);
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
if (candidates.length > 1) {
|
|
348
|
+
color.error(`${where}: junction table '${r.via}' has more than one ref column targeting ` + `'${to}' (${candidates.join(', ')}) — declare '${key}' explicitly`);
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
return candidates[0];
|
|
352
|
+
};
|
|
353
|
+
const viaSelf = resolveSide('viaSelf', r.viaSelf, tableName);
|
|
354
|
+
const viaTarget = resolveSide('viaTarget', r.viaTarget, r.target);
|
|
355
|
+
if (viaSelf == null || viaTarget == null) return null;
|
|
356
|
+
if (viaSelf === viaTarget) {
|
|
357
|
+
color.error(`${where}: 'viaSelf' and 'viaTarget' must be different columns of '${r.via}'`);
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
const businessKey = manifest.tables[r.via].businessKey ?? [];
|
|
361
|
+
if (!businessKey.includes(viaSelf) || !businessKey.includes(viaTarget)) {
|
|
362
|
+
color.error(`${where}: junction table '${r.via}' must declare a 'businessKey' containing ` + `both '${viaSelf}' and '${viaTarget}', so linking the same pair twice stays idempotent`);
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
relations.push({
|
|
366
|
+
name: name,
|
|
367
|
+
target: r.target
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Pass 2: emit per-table declarations.
|
|
373
|
+
const clients = [];
|
|
374
|
+
const txArms = [];
|
|
375
|
+
for (const tableName of tableNames) {
|
|
376
|
+
const table = manifest.tables[tableName];
|
|
377
|
+
const typeName = utils.snakeToCamelCase(tableName);
|
|
378
|
+
const columns = tableColumns[tableName];
|
|
379
|
+
const relations = tableRelations[tableName];
|
|
380
|
+
const rowLines = [`/** Row of \`${manifest.name}.${tableName}\`. */`, `export interface ${typeName}Row {`];
|
|
381
|
+
for (const [name, tsType] of domainSystemColumns) rowLines.push(` ${name}: ${tsType};`);
|
|
382
|
+
for (const c of columns) rowLines.push(` ${c.name}${c.required ? '' : '?'}: ${c.tsType};`);
|
|
383
|
+
rowLines.push('}');
|
|
384
|
+
decls.push(rowLines.join(sep));
|
|
385
|
+
const insertLines = [`/** Insert payload for \`${manifest.name}.${tableName}\`. */`, `export interface ${typeName}Insert {`];
|
|
386
|
+
for (const c of columns) insertLines.push(` ${c.name}${c.required ? '' : '?'}: ${c.insertType};`);
|
|
387
|
+
for (const r of relations) insertLines.push(` /** Link set: \`${r.target}\` row ids. */`, ` ${r.name}?: string[];`);
|
|
388
|
+
if (table.idempotency === true) insertLines.push(' idempotencyKey?: string;');
|
|
389
|
+
insertLines.push('}');
|
|
390
|
+
decls.push(insertLines.join(sep));
|
|
391
|
+
|
|
392
|
+
// Relations are write-side values, not columns: a patch takes them alongside
|
|
393
|
+
// the declared ones (set-replace over the links the caller can see), while
|
|
394
|
+
// reads surface them through the expand map below.
|
|
395
|
+
if (relations.length > 0) {
|
|
396
|
+
const updateLines = [`/** Update payload for \`${manifest.name}.${tableName}\`: declared ` + `columns plus relation link sets (each REPLACES the links you can see). */`, `export type ${typeName}Update = Partial<${typeName}Row> & {`];
|
|
397
|
+
for (const r of relations) updateLines.push(` ${r.name}?: string[];`);
|
|
398
|
+
updateLines.push('};');
|
|
399
|
+
decls.push(updateLines.join(sep));
|
|
400
|
+
}
|
|
401
|
+
decls.push(formatColumnUnion(`${typeName}Column`, [...domainSystemColumns.map(([name]) => name), ...columns.map(c => c.name)]));
|
|
402
|
+
|
|
403
|
+
// Expand map: MUST stay a `type` alias — object-type literals carry the implicit
|
|
404
|
+
// index signature the `TExpand extends {[key: string]: {}}` constraint needs
|
|
405
|
+
// (an interface would fail it).
|
|
406
|
+
const expandEntries = [];
|
|
407
|
+
for (const c of columns) {
|
|
408
|
+
if (c.ref == null) continue;
|
|
409
|
+
const fields = tableColumns[c.ref].map(tc => `'${c.name}.${tc.name}'?: ${tc.tsType}`);
|
|
410
|
+
expandEntries.push(wrapTokens(` '${c.name}': {`, fields, '; ', '};', ' '));
|
|
411
|
+
}
|
|
412
|
+
// A relation expands to the capped, display-name-ordered link array; the d42
|
|
413
|
+
// form (queryDf) flattens it into a chips column plus its '~<name>.id' companion.
|
|
414
|
+
// The link shape stays INLINE (structurally DG.DomainRelationLink): generated
|
|
415
|
+
// files must compile against the datagrok-api the package depends on, which may
|
|
416
|
+
// predate that interface.
|
|
417
|
+
for (const r of relations) expandEntries.push(` '${r.name}': {${r.name}?: {id: string; name: string}[]};`);
|
|
418
|
+
for (const childName of tableNames) {
|
|
419
|
+
const fks = tableColumns[childName].filter(c => c.ref === tableName);
|
|
420
|
+
if (fks.length === 0) continue;
|
|
421
|
+
const childType = utils.snakeToCamelCase(childName);
|
|
422
|
+
if (fks.length === 1) expandEntries.push(` 'details:${childName}': {${childName}?: ${childType}Row[]};`);else for (const fk of fks) expandEntries.push(` 'details:${childName}.${fk.name}': {${childName}?: ${childType}Row[]};`);
|
|
423
|
+
}
|
|
424
|
+
decls.push(expandEntries.length === 0 ? `export type ${typeName}Expand = {};` : [`/** Expand keys of \`${manifest.name}.${tableName}\` → fields each adds to the row ` + `(consumed by query()/builder). */`, `export type ${typeName}Expand = {`, ...expandEntries, '};'].join(sep));
|
|
425
|
+
txArms.push(` {op: 'insert'; table: '${tableName}'; ref?: string; values: DG.DomainTxValues<${typeName}Insert>} |`, ` {op: 'update'; table: '${tableName}'; id: string; ` + `values: DG.DomainTxValues<${relations.length > 0 ? `${typeName}Update` : `Partial<${typeName}Row>`}>; ` + `expectedVersion?: number} |`, ` {op: 'delete'; table: '${tableName}'; id: string} |`);
|
|
426
|
+
|
|
427
|
+
// No datetime config: the client resolves datetime columns from the domain
|
|
428
|
+
// registry itself (the Dayjs typing in <Table>Row stays true for every client).
|
|
429
|
+
// The fifth generic is the update payload — only a relation-bearing table has
|
|
430
|
+
// one of its own; without relations the client's `Partial<Row>` default is it.
|
|
431
|
+
clients.push(` get ${tableProp(tableName)}() {`, ` return grok.dapi.domains.table<${typeName}Row, ${typeName}Insert, ` + `${typeName}Column, ${typeName}Expand` + `${relations.length > 0 ? `, ${typeName}Update` : ''}>('${manifest.name}.${tableName}');`, ' },');
|
|
432
|
+
}
|
|
433
|
+
const schemaType = utils.snakeToCamelCase(manifest.name);
|
|
434
|
+
const lastArm = txArms.pop();
|
|
435
|
+
txArms.push(lastArm.replace(/ \|$/, ';'));
|
|
436
|
+
decls.push([`export type ${schemaType}TransactionOp =`, ...txArms].join(sep));
|
|
437
|
+
decls.push([`/** Typed clients for the \`${manifest.name}\` domain schema tables ` + `(lazy — no import-time side effects). */`, `export const ${utils.snakeToCamelCase(manifest.name, false)}Db = {`, ...clients, ` transaction<T extends ${schemaType}TransactionOp[]>(ops: [...T]):`, ` Promise<{[K in keyof T]: DG.DomainOpResultFor<T[K]>}> {`, ` return grok.dapi.domains.transaction('${manifest.name}', ops) as`, ` Promise<{[K in keyof T]: DG.DomainOpResultFor<T[K]>}>;`, ' },', '};'].join(sep));
|
|
438
|
+
return decls.join(sep.repeat(2)) + sep;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Emits the typed UI sugar of one manifest: per-table option types and a `<table>Ui`
|
|
442
|
+
* wrapper over the reflective `@datagrok-libraries/domain-ui` components, so app code gets
|
|
443
|
+
* this table's columns and row type checked. Collects the names it needs from `db.ts` into
|
|
444
|
+
* [dbImports]. Nothing here is imported by `db.ts` — data-only consumers gain no UI
|
|
445
|
+
* dependency. */
|
|
446
|
+
function generateDomainUiCode(manifest, dbImports) {
|
|
447
|
+
const decls = [];
|
|
448
|
+
const schemaClient = `${utils.snakeToCamelCase(manifest.name, false)}Db`;
|
|
449
|
+
dbImports.push(schemaClient);
|
|
450
|
+
for (const tableName of Object.keys(manifest.tables)) {
|
|
451
|
+
const type = utils.snakeToCamelCase(tableName);
|
|
452
|
+
const address = `${manifest.name}.${tableName}`;
|
|
453
|
+
// The generics of the client and the handle, in DomainTableClient's order. The
|
|
454
|
+
// fifth — the update payload — exists only for a relation-bearing table, whose
|
|
455
|
+
// update() also takes the link sets; without relations the `Partial<Row>`
|
|
456
|
+
// default is it. Emitting four there would make `update(id, {labels: [...]})`
|
|
457
|
+
// an excess-property error against a payload that has no `labels`.
|
|
458
|
+
const hasRelations = Object.keys(manifest.tables[tableName].relations ?? {}).length > 0;
|
|
459
|
+
const generics = `${type}Row, ${type}Insert, ${type}Column, ${type}Expand` + (hasRelations ? `, ${type}Update` : '');
|
|
460
|
+
const handle = `DomainTable<${generics}>`;
|
|
461
|
+
dbImports.push(`${type}Column`, `${type}Expand`, `${type}Insert`, `${type}Row`);
|
|
462
|
+
if (hasRelations) dbImports.push(`${type}Update`);
|
|
463
|
+
decls.push([`/** Query spec of \`${address}\` — columns and expand keys are compile-checked. */`, `export type ${type}QuerySpec = DG.DomainQuerySpec<${type}Column, keyof ${type}Expand & string>;`].join(sep), [`/** {@link DG.DomainQuery} parameters of \`${address}\` (schema and table are implied). */`, `export interface ${type}QueryParams extends`, ` Omit<DG.DomainQueryParams, 'schema' | 'table' | 'columns'> {`, ` columns?: ${type}Column[];`, '}'].join(sep), [`/** Options of {@link ${type}Ui.form} / {@link ${type}Ui.formDialog}. */`, `export interface ${type}FormOptions extends Omit<DomainFormOptions, 'values'> {`, ` values?: Partial<${type}Insert>;`, '}'].join(sep), [`/** Options of {@link ${type}Ui.grid}. */`, `export interface ${type}GridOptions extends Omit<DomainGridOptions, 'query' | 'defaults'> {`, ` query?: ${type}QuerySpec;`, ` defaults?: Partial<${type}Insert>;`, '}'].join(sep), [`/** Options of {@link ${type}Ui.list}. */`, `export interface ${type}ListOptions extends Omit<EntityListOptions, 'query'> {`, ` query?: ${type}QuerySpec;`, '}'].join(sep), [`/** Options of {@link ${type}Ui.listView} / {@link ${type}Ui.app}. */`, `export interface ${type}AppViewOptions extends Omit<DomainAppViewOptions, 'query'> {`, ` query?: ${type}QuerySpec;`, '}'].join(sep), ['/**', ` * Typed UI over \`${address}\`: the reflective components of`, ' * `@datagrok-libraries/domain-ui`, with this table\'s columns and row type checked at', ` * compile time. Reach it through {@link ${utils.snakeToCamelCase(tableName, false)}Ui}.`, ' */', `export class ${type}Ui {`, ` /** The table address, \`'<schema>.<table>'\`. */`, ` readonly address: string = '${address}';`, '', ` /** The typed client — the same one \`${schemaClient}.${tableProp(tableName)}\` returns. */`, ` get client(): DG.DomainTableClient<${generics}> {`, ` return ${schemaClient}.${tableProp(tableName)};`, ' }', '', ` /** The prefetched handle on \`${address}\` — THE async boundary, typed. Every`, ' * widget factory on it is synchronous, so acquire it ONCE when a page builds', ' * more than one widget; the shortcuts below acquire one of their own. */', ` table(): Promise<${handle}> {`, ` return domains.table<${generics}>(this.client);`, ' }', '', ' /** The handler registered for the table (the reflective default when none is). */', ' handler(): DG.DomainObjectHandler {', ' return domainHandler(this.address);', ' }', '', ' /** The property form of ONE row — a new one by default, an existing one with', ' * `{row}` or `{id}`; the values are this table\'s. */', ` async form(options?: ${type}FormOptions): Promise<DomainForm> {`, ' return (await this.table()).form(options);', ' }', '', ' /** {@link form} in a dialog; resolves to whether a row was saved. */', ` async formDialog(options?: ${type}FormOptions & DomainDialogOptions): Promise<boolean> {`, ' return (await this.table()).formDialog(options);', ' }', '', ' /** The browse/CRUD page: list, search, New, and a deep-linkable query. */', ` async listView(options?: ${type}AppViewOptions): Promise<DomainAppView> {`, ' return (await this.table()).listView(options);', ' }', '', ' /** THE app — {@link listView} under the name that says what it is. */', ` app(options?: ${type}AppViewOptions): Promise<DomainAppView> {`, ' return this.listView(options);', ' }', '', ' /** The row page: form, detail tabs, history. Takes the row or its id. */', ' async entityView(row: string | DG.DomainRow,', ' options?: DomainEntityAppViewOptions): Promise<DomainEntityAppView> {', ' return (await this.table()).entityView(row, options);', ' }', '', ' /** An editable grid: batch editing, one-transaction save. */', ` async grid(options?: ${type}GridOptions): Promise<DomainGrid> {`, ' return (await this.table()).grid(options);', ' }', '', ' /** A list of rows (cards / brief / grid). */', ` async list(options?: ${type}ListOptions): Promise<EntityListWidget> {`, ' return (await this.table()).list(options);', ' }', '', ' /** Opens the platform\'s create dialog, or the edit dialog for [row]. */', ' edit(row?: DG.DomainRow): Promise<boolean> {', ' return this.handler().editRow(row);', ' }', '', ' /** Opens the platform\'s row picker. */', ' pick(): Promise<DG.DomainRow | null> {', ' return this.handler().pickRow();', ' }', '', ' /** Wraps one `query()` row as a {@link DG.DomainRow} — locally, no round trip. */', ` row(values: Partial<${type}Row> | null): DG.DomainRow {`, ' return this.handler().rowFrom(values);', ' }', '', ' /** A serializable query over the table: deep links, saved filters, Open in Table View. */', ` query(params?: ${type}QueryParams): DG.DomainQuery {`, ` return new DG.DomainQuery({...params, schema: '${manifest.name}', table: '${tableName}'});`, ' }', '}'].join(sep), [`/** Typed UI over \`${address}\` (see {@link ${type}Ui}). */`, `export const ${utils.snakeToCamelCase(tableName, false)}Ui = new ${type}Ui();`].join(sep));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// The schema-level handle: every table's DomainTable under ONE await, typed
|
|
467
|
+
// (`const db = await <schema>UiDb(); db.<plural>.form(...)`).
|
|
468
|
+
const schemaType = utils.snakeToCamelCase(manifest.name);
|
|
469
|
+
const tableProps = Object.keys(manifest.tables).filter(t => !domainDbReservedProps.includes(tableProp(t)));
|
|
470
|
+
decls.push(['/**', ` * The typed schema handle over \`${manifest.name}\`: one prefetched {@link DomainTable}`, ' * per table, resolved together by {@link ' + utils.snakeToCamelCase(manifest.name, false) + 'UiDb} —', ' * the schema-level async boundary (see `domains.db`).', ' */', `export interface ${schemaType}UiDb extends DomainDb {`, ...tableProps.map(t => {
|
|
471
|
+
const type = utils.snakeToCamelCase(t);
|
|
472
|
+
const update = Object.keys(manifest.tables[t].relations ?? {}).length > 0 ? `, ${type}Update` : '';
|
|
473
|
+
return ` readonly ${tableProp(t)}: ` + `DomainTable<${type}Row, ${type}Insert, ${type}Column, ${type}Expand${update}>;`;
|
|
474
|
+
}), '}'].join(sep), [`/** Acquires the {@link ${schemaType}UiDb} handle — every table of`, ` * \`${manifest.name}\`, prefetched together (see \`domains.db\`). */`, `export function ${utils.snakeToCamelCase(manifest.name, false)}UiDb(): Promise<${schemaType}UiDb> {`, ` return domains.db('${manifest.name}') as Promise<${schemaType}UiDb>;`, '}'].join(sep));
|
|
475
|
+
return decls.join(sep.repeat(2)) + sep;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Joins [tokens] into `<prefix>t1<sepToken>t2...<suffix>` lines wrapped at the 120-char
|
|
479
|
+
* limit with a hanging [indent]. */
|
|
480
|
+
function wrapTokens(prefix, tokens, sepToken, suffix, indent = ' ') {
|
|
481
|
+
const lines = [];
|
|
482
|
+
let line = prefix;
|
|
483
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
484
|
+
const token = tokens[i] + (i < tokens.length - 1 ? sepToken : suffix);
|
|
485
|
+
if (line.length + token.length > 118 && line.trim().length > 0) {
|
|
486
|
+
lines.push(line.trimEnd());
|
|
487
|
+
line = indent;
|
|
488
|
+
}
|
|
489
|
+
line += token;
|
|
490
|
+
}
|
|
491
|
+
lines.push(line);
|
|
492
|
+
return lines.join(sep);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Formats `export type <name> = 'a' | 'b' | ...;` wrapped to the 120-char line limit. */
|
|
496
|
+
function formatColumnUnion(name, values) {
|
|
497
|
+
const lines = [];
|
|
498
|
+
let line = `export type ${name} = `;
|
|
499
|
+
for (let i = 0; i < values.length; i++) {
|
|
500
|
+
const token = `'${values[i]}'` + (i < values.length - 1 ? ' | ' : ';');
|
|
501
|
+
if (line.length + token.length > 118) {
|
|
502
|
+
lines.push(line.trimEnd());
|
|
503
|
+
line = ' ';
|
|
504
|
+
}
|
|
505
|
+
line += token;
|
|
506
|
+
}
|
|
507
|
+
lines.push(line);
|
|
508
|
+
return lines.join(sep);
|
|
509
|
+
}
|
|
133
510
|
function api(args) {
|
|
134
511
|
color.setVerbose(args.verbose || args.v || false);
|
|
135
512
|
_package = JSON.parse(_fs.default.readFileSync(packagePath, {
|
|
136
513
|
encoding: 'utf-8'
|
|
137
514
|
}));
|
|
138
515
|
if (_package.friendlyName) _package.friendlyName = _package.friendlyName.replaceAll(' ', '');
|
|
139
|
-
const nOptions = Object.keys(args).length - 1 - (args.verbose ? 1 : 0) - (args.v ? 1 : 0);
|
|
516
|
+
const nOptions = Object.keys(args).length - 1 - (args.verbose ? 1 : 0) - (args.v ? 1 : 0) - (args.ui ? 1 : 0);
|
|
140
517
|
if (args['_'].length !== 1 || nOptions > 0) return false;
|
|
141
518
|
if (!utils.isPackageDir(process.cwd())) {
|
|
142
519
|
color.error('File `package.json` not found. Run the command from the package directory');
|
|
@@ -146,5 +523,7 @@ function api(args) {
|
|
|
146
523
|
generateScriptWrappers();
|
|
147
524
|
generateQueryWrappers();
|
|
148
525
|
generateFunctionWrappers();
|
|
149
|
-
return
|
|
526
|
+
return generateDomainClients(curDir, {
|
|
527
|
+
ui: args.ui ?? false
|
|
528
|
+
});
|
|
150
529
|
}
|
package/bin/commands/help.js
CHANGED
|
@@ -78,6 +78,8 @@ Usage: grok add <entity> <name>
|
|
|
78
78
|
Add an object template to your package:
|
|
79
79
|
|
|
80
80
|
grok add app <name>
|
|
81
|
+
grok add app [name] --domain <schema>[.<table>]
|
|
82
|
+
grok add app [name] --domain <path to schema.json>
|
|
81
83
|
grok add connection <name>
|
|
82
84
|
grok add detector <semantic-type-name>
|
|
83
85
|
grok add function [tag] <name>
|
|
@@ -89,6 +91,15 @@ grok add tests
|
|
|
89
91
|
|
|
90
92
|
Please note that entity names may only include letters and numbers
|
|
91
93
|
|
|
94
|
+
--domain scaffolds a working browse/CRUD app over an entity-mapped domain table
|
|
95
|
+
(\`grok.dapi.domains\`) from the \`@datagrok-libraries/domain-ui\` defaults. Give it
|
|
96
|
+
one table (\`--domain grit.issue\`), a whole schema the package declares in
|
|
97
|
+
\`databases/<schema>/schema.json\` (one app per table), or the path to a schema
|
|
98
|
+
manifest to copy into the package. A fresh app package is two commands:
|
|
99
|
+
|
|
100
|
+
grok create MyTracker
|
|
101
|
+
cd MyTracker && grok add app --domain grit.issue
|
|
102
|
+
|
|
92
103
|
Supported languages for scripts:
|
|
93
104
|
javascript, julia, node, octave, python, r
|
|
94
105
|
|
|
@@ -113,12 +124,18 @@ Options:
|
|
|
113
124
|
const HELP_API = `
|
|
114
125
|
Usage: grok api
|
|
115
126
|
|
|
116
|
-
Create wrapper functions for package scripts and queries
|
|
127
|
+
Create wrapper functions for package scripts and queries.
|
|
128
|
+
Packages with \`databases/<schema>/schema.json\` manifests also get typed domain
|
|
129
|
+
clients in \`src/generated/db.ts\`.
|
|
117
130
|
|
|
118
131
|
Options:
|
|
119
|
-
[-v | --verbose]
|
|
132
|
+
[-v | --verbose] [--ui]
|
|
120
133
|
|
|
121
134
|
--verbose Print detailed output
|
|
135
|
+
--ui Also generate \`src/generated/db-ui.ts\` — typed UI wrappers over
|
|
136
|
+
\`@datagrok-libraries/domain-ui\` for every domain table. Once the
|
|
137
|
+
file exists, plain \`grok api\` keeps it up to date; delete it to
|
|
138
|
+
opt out again
|
|
122
139
|
`;
|
|
123
140
|
const HELP_CONFIG = `
|
|
124
141
|
Usage: grok config
|