datagrok-tools 6.5.6 → 6.5.8

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.
@@ -82,6 +82,49 @@ function rowsToCsv(rows) {
82
82
  data: rows.map(r => header.map(h => r[h]))
83
83
  });
84
84
  }
85
+ function writePlaywrightCsv(pkgDir, csv) {
86
+ // Persist a Playwright-only CSV so the pipeline can ship it to the Datlas
87
+ // 'playwright' bucket, separate from the merged Puppeteer+Playwright
88
+ // test-report.csv that feeds the legacy 'package' bucket and JUnit.
89
+ try {
90
+ _fs.default.writeFileSync(_path.default.join(pkgDir, 'test-report-playwright.csv'), csv, 'utf-8');
91
+ } catch (e) {
92
+ color.warn(`Playwright: failed to write test-report-playwright.csv: ${e.message || e}`);
93
+ }
94
+ }
95
+
96
+ // A pass that dies before running specs still has to report a failure. Returning an empty CSV
97
+ // let the caller drop it whenever the Puppeteer pass had rows, so a suite that never launched
98
+ // (a config that fails to load, an unreachable host) was reported as a clean run.
99
+ function runnerFailure(pkgDir, args, message) {
100
+ const rows = [{
101
+ date: new Date().toISOString(),
102
+ category: 'playwright',
103
+ name: 'Playwright suite',
104
+ success: false,
105
+ result: message,
106
+ ms: 0,
107
+ skipped: false,
108
+ logs: '',
109
+ owner: '',
110
+ package: process.env.TARGET_PACKAGE || args.package || '',
111
+ widgetsDifference: '',
112
+ flaking: false
113
+ }];
114
+ const csv = rowsToCsv(rows);
115
+ writePlaywrightCsv(pkgDir, csv);
116
+ color.error(`Playwright: ${message}`);
117
+ return {
118
+ failed: true,
119
+ passedAmount: 0,
120
+ failedAmount: 1,
121
+ skippedAmount: 0,
122
+ verbosePassed: '',
123
+ verboseSkipped: '',
124
+ verboseFailed: `Playwright: ${message}\n`,
125
+ csv: csv
126
+ };
127
+ }
85
128
  async function runPlaywrightTests(pkgDir, testDir, args, hostKey) {
86
129
  const empty = {
87
130
  failed: false,
@@ -101,29 +144,17 @@ async function runPlaywrightTests(pkgDir, testDir, args, hostKey) {
101
144
  key
102
145
  } = testUtils.getDevKey(hostKey));
103
146
  } catch (e) {
104
- color.error(`Playwright: cannot resolve host '${hostKey}': ${e.message || e}`);
105
- return {
106
- ...empty,
107
- failed: true,
108
- failedAmount: 1,
109
- verboseFailed: `Playwright: ${e.message || e}\n`
110
- };
147
+ return runnerFailure(pkgDir, args, `cannot resolve host '${hostKey}': ${e.message || e}`);
111
148
  }
112
149
  let token;
113
150
  try {
114
151
  token = await testUtils.getToken(url, key);
115
152
  } catch (e) {
116
- color.error(`Playwright: cannot exchange dev key for token: ${e.message || e}`);
117
- return {
118
- ...empty,
119
- failed: true,
120
- failedAmount: 1,
121
- verboseFailed: `Playwright: ${e.message || e}\n`
122
- };
153
+ return runnerFailure(pkgDir, args, `cannot exchange dev key for token: ${e.message || e}`);
123
154
  }
124
155
  let webUrl;
125
156
  try {
126
- webUrl = await testUtils.getWebUrl(url, token);
157
+ webUrl = process.env.DATAGROK_WEB_URL || (await testUtils.getWebUrl(url, token));
127
158
  if (webUrl.endsWith('/')) webUrl = webUrl.slice(0, -1);
128
159
  } catch {
129
160
  webUrl = url.replace(/\/api\/?$/, '');
@@ -137,15 +168,7 @@ async function runPlaywrightTests(pkgDir, testDir, args, hostKey) {
137
168
  }
138
169
  }
139
170
  const configPath = _path.default.join(testDir, 'playwright.config.ts');
140
- if (!_fs.default.existsSync(configPath)) {
141
- color.error(`Playwright: ${configPath} not found.`);
142
- return {
143
- ...empty,
144
- failed: true,
145
- failedAmount: 1,
146
- verboseFailed: 'Playwright: missing playwright.config.ts\n'
147
- };
148
- }
171
+ if (!_fs.default.existsSync(configPath)) return runnerFailure(pkgDir, args, `${configPath} not found`);
149
172
  const reportFile = _path.default.join(pkgDir, 'test-playwright-report.json');
150
173
  if (_fs.default.existsSync(reportFile)) _fs.default.unlinkSync(reportFile);
151
174
  const cliArgs = ['--no-install', 'playwright', 'test', `--config=${configPath}`];
@@ -168,6 +191,8 @@ async function runPlaywrightTests(pkgDir, testDir, args, hostKey) {
168
191
  ...process.env,
169
192
  DATAGROK_URL: webUrl,
170
193
  DATAGROK_AUTH_TOKEN: token,
194
+ DATAGROK_API_URL: url,
195
+ DATAGROK_DEV_KEY: key,
171
196
  PLAYWRIGHT_JSON_OUTPUT_NAME: reportFile
172
197
  };
173
198
  if (token2) env.DATAGROK_AUTH_TOKEN_2 = token2;
@@ -219,20 +244,18 @@ async function runPlaywrightTests(pkgDir, testDir, args, hostKey) {
219
244
  } catch {/* ignore */}
220
245
  }
221
246
  if (!report) {
222
- color.error('Playwright: no JSON report produced.');
223
247
  const tail = Buffer.concat(stderrChunks).toString('utf-8').slice(-2000);
224
- return {
225
- ...empty,
226
- failed: true,
227
- failedAmount: 1,
228
- verboseFailed: `Playwright: no JSON report. stderr tail:\n${tail}\n`
229
- };
248
+ return runnerFailure(pkgDir, args, `no JSON report produced — the suite never ran. stderr tail:\n${tail}`);
230
249
  }
231
250
  const pkgJson = JSON.parse(_fs.default.readFileSync(_path.default.join(pkgDir, 'package.json'), 'utf-8'));
232
251
  const owner = pkgJson.author && (pkgJson.author.email || pkgJson.author) || '';
233
252
  const pkgName = process.env.TARGET_PACKAGE || args.package || pkgJson.name || '';
234
253
  const rows = [];
235
254
  flattenSuites(report.suites, testDir, pkgName, typeof owner === 'string' ? owner : '', args.verbose === true, rows);
255
+
256
+ // A report with no specs is the same non-event as no report at all: the config loaded but
257
+ // collection found nothing, or died on a spec that cannot be imported.
258
+ if (rows.length === 0) return runnerFailure(pkgDir, args, 'the run produced a report with no specs — check the spec imports and testMatch');
236
259
  let passedAmount = 0;
237
260
  let failedAmount = 0;
238
261
  let skippedAmount = 0;
@@ -253,14 +276,7 @@ async function runPlaywrightTests(pkgDir, testDir, args, hostKey) {
253
276
  }
254
277
  }
255
278
  const csv = rowsToCsv(rows);
256
- // Persist a Playwright-only CSV so the pipeline can ship it to the Datlas
257
- // 'playwright' bucket, separate from the merged Puppeteer+Playwright
258
- // test-report.csv that feeds the legacy 'package' bucket and JUnit.
259
- try {
260
- _fs.default.writeFileSync(_path.default.join(pkgDir, 'test-report-playwright.csv'), csv, 'utf-8');
261
- } catch (e) {
262
- color.warn(`Playwright: failed to write test-report-playwright.csv: ${e.message || e}`);
263
- }
279
+ writePlaywrightCsv(pkgDir, csv);
264
280
  return {
265
281
  failed: failedAmount > 0,
266
282
  passedAmount: passedAmount,
@@ -6,10 +6,23 @@ Object.defineProperty(exports, "__esModule", {
6
6
  exports.createClient = createClient;
7
7
  var _nodeDapi = require("./node-dapi");
8
8
  var _testUtils = require("./test-utils");
9
- async function createClient(hostArg) {
9
+ /**
10
+ * `--admin` asks the server for an admin session, which lifts the permission filter for this run:
11
+ * without it a stand-wide pull sees only what the key's own account can, and content in other
12
+ * people's spaces is invisible. The server authorises it (`START_ADMIN_SESSION`) and signs the
13
+ * flag into the token, and a dev key mints a fresh session per invocation, so it cannot outlive
14
+ * the command or reach another session.
15
+ */
16
+ async function createClient(hostArg, admin = false) {
10
17
  const {
11
18
  url,
12
19
  key
13
20
  } = (0, _testUtils.getDevKey)(hostArg ?? '');
14
- return _nodeDapi.NodeApiClient.login(url, key);
21
+ const client = await _nodeDapi.NodeApiClient.login(url, key);
22
+ if (!admin) return client;
23
+ const token = (await client.post('/users/sessions/current/admin'))?.token;
24
+ if (!token) throw new Error(`${url} refused an admin session — the account behind this key cannot start one`);
25
+ client.token = token;
26
+ client.adminMode = true;
27
+ return client;
15
28
  }
@@ -9,8 +9,16 @@ exports.getKeys = getKeys;
9
9
  exports.printBatchOutput = printBatchOutput;
10
10
  exports.printError = printError;
11
11
  exports.printOutput = printOutput;
12
+ exports.progressReporter = progressReporter;
13
+ exports.setOutputFormat = setOutputFormat;
12
14
  /// Docs: [Grok Dapi](/docs/plans/grok-dapi/)
13
15
 
16
+ let errorFormat = 'table';
17
+
18
+ /** Set once per run so every `printError` call site reports in the requested format. */
19
+ function setOutputFormat(format) {
20
+ errorFormat = format;
21
+ }
14
22
  function printOutput(data, format) {
15
23
  if (data === null || data === undefined) {
16
24
  if (format !== 'quiet') console.log('(empty)');
@@ -118,10 +126,63 @@ function printBatchOutput(response, format) {
118
126
  error: r.error
119
127
  })), null, 2) + '\n');
120
128
  }
121
- function printError(err) {
129
+ function printError(err, opts = {}) {
122
130
  const apiErr = err?.apiError;
123
- const out = apiErr ?? {
124
- error: String(err?.message ?? err)
131
+ const status = apiErr?.errorCode;
132
+ const base = apiErr?.error ?? String(err?.message ?? err);
133
+ const message = typeof status === 'number' && status >= 400 && !base.includes(String(status)) ? `${base} (HTTP ${status})` : base;
134
+ if (errorFormat === 'json') {
135
+ process.stderr.write(JSON.stringify({
136
+ ...apiErr,
137
+ error: message
138
+ }) + '\n');
139
+ return;
140
+ }
141
+ process.stderr.write(`${message}\n`);
142
+ printErrorDetails(apiErr?.body);
143
+ if (opts.verbose && apiErr?.stackTrace) process.stderr.write(apiErr.stackTrace + '\n');
144
+ }
145
+
146
+ /** Structured fields of a domain error envelope: per-row validation errors, manifest errors, a plan awaiting confirmation. */
147
+ function printErrorDetails(body) {
148
+ if (!body || typeof body !== 'object') return;
149
+ for (const r of Array.isArray(body.rows) ? body.rows : []) for (const e of Array.isArray(r?.errors) ? r.errors : []) process.stderr.write(` row ${r.index ?? ''}${e?.column ? ` ${e.column}` : ''}: ${e?.message ?? e?.code ?? ''}\n`);
150
+ for (const e of Array.isArray(body.errors) ? body.errors : []) process.stderr.write(` ${typeof e === 'string' ? e : e?.message ?? JSON.stringify(e)}\n`);
151
+ if (body.plan) process.stderr.write(JSON.stringify(body.plan, null, 2) + '\n');
152
+ }
153
+
154
+ /**
155
+ * Progress for the long migration walks. A whole-stand pull is minutes of network waiting with
156
+ * nothing to show for it, and silence is indistinguishable from a hang. Goes to stderr, so it
157
+ * shows even under `--output json` without disturbing the document, and rewrites a single line
158
+ * on a terminal.
159
+ */
160
+ function progressReporter(quiet = false) {
161
+ if (quiet) return () => {};
162
+ const tty = process.stderr.isTTY;
163
+ let last = '';
164
+ let drawn = 0;
165
+ return (stage, done, total) => {
166
+ const count = done === undefined ? '' : total === undefined ? ` ${done}` : ` ${done}/${total}`;
167
+ const line = `${stage}${count}`;
168
+ if (!tty) {
169
+ // A log cannot be rewritten in place, so it gets a line every so often rather than one per
170
+ // item — enough to show the run is alive, few enough to stay readable.
171
+ if (line !== last && (done === undefined || done === total || done % 250 === 0)) process.stderr.write(`${line}\n`);
172
+ last = line;
173
+ return;
174
+ }
175
+ // Redrawing on every item writes megabytes a minute into anything that is not really a
176
+ // terminal — a detached run produced a 7 GB file of carriage returns. The eye cannot
177
+ // follow faster than this anyway.
178
+ const finished = done !== undefined && done === total;
179
+ if (!finished && Date.now() - drawn < 100) return;
180
+ drawn = Date.now();
181
+ process.stderr.write(`\r${' '.repeat(last.length)}\r${line}`);
182
+ last = line;
183
+ if (finished) {
184
+ process.stderr.write('\n');
185
+ last = '';
186
+ }
125
187
  };
126
- process.stderr.write(JSON.stringify(out, null, 2) + '\n');
127
188
  }
@@ -127,7 +127,7 @@ function getDevKey(hostKey) {
127
127
  let key = '';
128
128
  let url = '';
129
129
  try {
130
- let url = new URL(host).href;
130
+ url = new URL(host).href;
131
131
  if (url.endsWith('/')) url = url.slice(0, -1);
132
132
  if (url in urls) key = config['servers'][urls[url]]['key'];
133
133
  } catch (error) {
@@ -77,12 +77,12 @@
77
77
  "promotion": {
78
78
  "enum": ["lazy", "eager"],
79
79
  "default": "lazy",
80
- "description": "Row mode only. lazy: entities row created on first share; eager: at insert."
80
+ "description": "Row mode only. lazy: entities row created on first share; eager: at insert, with the author granted View/Edit/Delete/Share."
81
81
  },
82
82
  "defaultRowVisibility": {
83
83
  "enum": ["table", "none"],
84
84
  "default": "table",
85
- "description": "Row/master modes: whether table-level View shows unshared rows."
85
+ "description": "Row/master modes: whether table-level View shows unshared rows. Their author always sees, edits and shares them; none hides them from everyone else."
86
86
  },
87
87
  "delegate": {
88
88
  "$ref": "#/definitions/identifier",
@@ -138,6 +138,29 @@
138
138
  "items": {"$ref": "#/definitions/identifier"},
139
139
  "description": "Property schemas contributing jsonb keys to this table."
140
140
  },
141
+ "constraints": {
142
+ "type": "object",
143
+ "description": "Table-level CHECK constraints keyed by constraint name. The expression may reference this table's relational columns, literals, comparison/arithmetic operators and a whitelist of keywords and immutable functions; it is normalized before it reaches the DDL.",
144
+ "propertyNames": {"$ref": "#/definitions/identifier"},
145
+ "additionalProperties": {
146
+ "type": "object",
147
+ "required": ["check"],
148
+ "additionalProperties": false,
149
+ "properties": {
150
+ "check": {"type": "string"}
151
+ }
152
+ }
153
+ },
154
+ "grants": {
155
+ "type": "object",
156
+ "description": "Permissions granted on the table entity at deploy time, keyed by group name (a platform group such as 'All users' or 'Developers'); each lists the permissions to grant.",
157
+ "minProperties": 1,
158
+ "additionalProperties": {
159
+ "type": "array",
160
+ "minItems": 1,
161
+ "items": {"enum": ["view", "edit", "delete"]}
162
+ }
163
+ },
141
164
  "relations": {
142
165
  "type": "object",
143
166
  "description": "Many-to-many relations of this table keyed by relation name: each links the table to a target table through a junction table. The name shares one namespace with the columns (it is what expand and filter paths address), so it may not collide with a column of this table. Declaration order is preserved and is the order every UI surface lays the relations out in.",
@@ -211,8 +234,8 @@
211
234
  "additionalProperties": false,
212
235
  "properties": {
213
236
  "type": {
214
- "enum": ["string", "int", "float", "bool", "datetime", "string_list", "ref", "user", "group", "file"],
215
- "description": "Property type; ref = FK to another table in this manifest; user/group = FK to the core users/groups tables with the matching semantic type; file = a file://<connection>/<path> string into platform file storage (semantic type File)."
237
+ "enum": ["string", "int", "float", "bool", "datetime", "string_list", "ref", "file", "json", "user", "group"],
238
+ "description": "Property type; ref = FK to another table in this manifest; user/group = aliases of ref Core.users / ref Core.groups (semantic type User / Group); file = a file://<connection>/<path> string into platform file storage (semantic type File); json = a jsonb-backed opaque object (filters, sorts, facets and aggregates refuse it)."
216
239
  },
217
240
  "required": {"type": "boolean", "default": false},
218
241
  "unique": {
@@ -220,14 +243,42 @@
220
243
  "default": false,
221
244
  "description": "Unique among live (not soft-deleted) rows."
222
245
  },
246
+ "immutable": {
247
+ "type": "boolean",
248
+ "default": false,
249
+ "description": "Write-once: the first non-null value wins; a later change is refused."
250
+ },
251
+ "autoNumber": {
252
+ "description": "Auto-numbering (int columns only; implies immutable): a blank value on insert is assigned from a monotonic counter, a supplied value is kept (the counter catches up). true = one counter per table; {scope} = one counter per master row of the named ref column of this table. Changing it on an existing column (on, off, or a scope change) is a physical change that needs a migration.",
253
+ "oneOf": [
254
+ {"enum": [true]},
255
+ {
256
+ "type": "object",
257
+ "additionalProperties": false,
258
+ "properties": {
259
+ "scope": {
260
+ "$ref": "#/definitions/identifier",
261
+ "description": "A required ref column of this table; numbering restarts per referenced row."
262
+ },
263
+ "start": {
264
+ "type": "integer",
265
+ "minimum": 1,
266
+ "default": 1,
267
+ "description": "First number assigned by a fresh counter."
268
+ }
269
+ }
270
+ }
271
+ ]
272
+ },
223
273
  "isName": {
224
274
  "type": "boolean",
225
275
  "default": false,
226
276
  "description": "Marks the primary display-name column (string columns only; at most one per table). Its value titles cards, tooltips, and entity views, and becomes the friendly name of promoted rows. Convention fallback: a string column literally named 'name'."
227
277
  },
228
278
  "ref": {
229
- "$ref": "#/definitions/identifier",
230
- "description": "Target table name (type = ref only). Cross-plugin refs are not allowed."
279
+ "type": "string",
280
+ "pattern": "^([a-z][a-z0-9_]*|[A-Za-z][A-Za-z0-9_]*\\.[a-z][a-z0-9_]*)$",
281
+ "description": "Target table (type = ref only): a table of this schema, or the qualified '<Schema>.<table>' form for any registered table — a Core table such as 'Core.users' or 'Core.queries', or a table of another plugin's schema. The reference is a hard foreign key only for same-schema targets and Core.users/Core.groups; every other target is a soft reference (no FK constraint) that may dangle after the target is hard-deleted."
231
282
  },
232
283
  "onDelete": {
233
284
  "enum": ["cascade", "restrict", "setnull"],
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "datagrok-tools",
3
- "version": "6.5.6",
3
+ "repository": {
4
+ "type": "git",
5
+ "url": "https://github.com/datagrok-ai/public.git"
6
+ },
7
+ "version": "6.5.8",
4
8
  "description": "Utility to upload and publish packages to Datagrok",
5
9
  "homepage": "https://github.com/datagrok-ai/public/tree/master/tools#readme",
6
10
  "dependencies": {
@@ -32,6 +36,7 @@
32
36
  "update:ivp-parser": "esbuild plugins/ivp-parser.entry.mjs --bundle --format=cjs --platform=node --alias:diff-grok=../libraries/compute-utils/node_modules/diff-grok --outfile=plugins/ivp-parser.bundle.cjs",
33
37
  "debug-source-map": "node build.js --source-maps",
34
38
  "test": "vitest run --project unit",
39
+ "test:server": "vitest run --project unit bin/__tests__/node-dapi bin/__tests__/server bin/__tests__/migrate",
35
40
  "test:watch": "vitest --project unit",
36
41
  "test:integration": "vitest run --project integration",
37
42
  "test:all": "vitest run"
File without changes