@isrd-isi-edu/ermrestjs 2.10.0 → 2.11.1

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/js/hatrac.js CHANGED
@@ -4,7 +4,7 @@ import { ArrayBuffer } from 'spark-md5';
4
4
 
5
5
  // models
6
6
  // import DeferredPromise from '@isrd-isi-edu/ermrestjs/src/models/deferred-promise';
7
- import { MalformedURIError } from '@isrd-isi-edu/ermrestjs/src/models/errors';
7
+ import { InvalidInputError, MalformedURIError } from '@isrd-isi-edu/ermrestjs/src/models/errors';
8
8
 
9
9
  // services
10
10
  import ErrorService from '@isrd-isi-edu/ermrestjs/src/services/error';
@@ -231,7 +231,7 @@ Upload.prototype.validateURL = function (row, linkedData, templateVariables) {
231
231
  // TODO is this needed?
232
232
  // make sure to add raw columns too.
233
233
  ignoredColumns.forEach(function (col) {
234
- ignoredColumns.push("_" + col);
234
+ ignoredColumns.push('_' + col);
235
235
  });
236
236
 
237
237
  var keyValues = _getFormattedKeyValues(this.reference.table, this.reference._context, row, linkedData);
@@ -503,7 +503,12 @@ Upload.prototype.start = function (startChunkIdx, onProgress) {
503
503
  this.chunks = [];
504
504
 
505
505
  if (this.file.size === 0) {
506
- deferred.resolve(this.url);
506
+ // empty (0-byte) uploads are rejected unless the asset annotation opts in via allow_empty_file
507
+ if (this.column.allowEmptyFile) {
508
+ deferred.resolve(this.url);
509
+ } else {
510
+ deferred.reject(new InvalidInputError(`The file "${this.file.name}" is empty (0 bytes). Empty files are not allowed.`));
511
+ }
507
512
  return deferred.promise;
508
513
  } else {
509
514
  while (start < this.file.size) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@isrd-isi-edu/ermrestjs",
3
3
  "description": "ERMrest client library in JavaScript",
4
- "version": "2.10.0",
4
+ "version": "2.11.1",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
7
7
  "node": ">= 20.0.0",
@@ -55,7 +55,7 @@
55
55
  "@types/lodash-es": "^4.17.12",
56
56
  "@types/markdown-it": "^14.1.2",
57
57
  "@types/q": "^1.5.8",
58
- "axios": "1.16.0",
58
+ "axios": "1.18.1",
59
59
  "handlebars": "4.7.9",
60
60
  "lodash-es": "^4.18.1",
61
61
  "lz-string": "^1.5.0",
@@ -67,20 +67,20 @@
67
67
  "q": "1.5.1",
68
68
  "spark-md5": "^3.0.0",
69
69
  "terser": "^5.44.1",
70
- "typescript": "~5.9.3",
71
- "vite": "^8.0.16",
70
+ "typescript": "~6.0.3",
71
+ "vite": "^8.1.2",
72
72
  "vite-plugin-compression2": "^2.5.3"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@commitlint/cli": "^20.2.0",
76
76
  "@commitlint/config-conventional": "^20.2.0",
77
- "@eslint/js": "^9.39.2",
77
+ "@eslint/js": "^10.0.1",
78
78
  "@isrd-isi-edu/ermrest-data-utils": "0.0.5",
79
- "@types/node": "^25.0.3",
80
- "eslint": "^9.39.2",
79
+ "@types/node": "^26.1.0",
80
+ "eslint": "^10.6.0",
81
81
  "eslint-config-prettier": "^10.1.8",
82
82
  "eslint-plugin-prettier": "^5.5.4",
83
- "globals": "^17.4.0",
83
+ "globals": "^17.7.0",
84
84
  "husky": "^9.1.7",
85
85
  "jasmine": "2.5.3",
86
86
  "jasmine-expect": "3.7.1",
@@ -89,6 +89,6 @@
89
89
  "prettier": "^3.8.1",
90
90
  "require-reload": "^0.2.2",
91
91
  "rollup-plugin-visualizer": "^6.0.3",
92
- "typescript-eslint": "^8.51.0"
92
+ "typescript-eslint": "^8.62.1"
93
93
  }
94
94
  }
@@ -930,18 +930,22 @@ export class Reference {
930
930
  if (isDetailed) {
931
931
  this.generateRelatedList(tuple).forEach((rel, i) => {
932
932
  const rc = rel.pseudoColumn?.resolvedCondition;
933
+ // source hash of this related entity, used by the consolidation pass to match
934
+ // data consumers (wait_fors, citation, condition sources) of the same source.
935
+ // Mirrors how generateRelatedList computes fkName.
936
+ const relSourceName = rel.pseudoColumn ? rel.pseudoColumn.name : (_sourceColumnHelpers.generateForeignKeyName(rel.origFKR, true) as string);
933
937
  /**
934
938
  * if there's a condition, we have to capture the requests generated for the related entity as its dependencies.
935
- * So when the condition is evaludated, we can decide whether to run those requests or not.
939
+ * So when the condition is evaluated, we can decide whether to run those requests or not.
936
940
  */
937
941
  const addRelatedToDeps = (deps: Array<ActiveListRequest | ActiveListRelatedEntityRequest>) => {
938
- deps.push({ related: true, index: i });
942
+ deps.push({ related: true, index: i, entitysetSourceName: relSourceName });
939
943
  rel.pseudoColumn?.waitFor.forEach((wf) => {
940
944
  builder.addColToDependent(deps, wf, true, ActiveListRequestTypes.RELATED, i);
941
945
  });
942
946
  };
943
947
  if (tryCondition(rc, { related: true, index: i }, addRelatedToDeps)) return;
944
- builder.requests.push({ related: true, index: i });
948
+ builder.requests.push({ related: true, index: i, entitysetSourceName: relSourceName });
945
949
  rel.pseudoColumn?.waitFor.forEach((wf) => {
946
950
  builder.addCol(wf, true, ActiveListRequestTypes.RELATED, i);
947
951
  });
@@ -41,6 +41,7 @@ type AssetMetadata = {
41
41
  * @property {(Column|boolean|null)} md5 if it's string, then it is the name of column we want to store md5 inside of it. If it's true, that means we must use md5.
42
42
  * @property {(Column|boolean|null)} sha256 if it's string, then it is the name of column we want to store sha256 inside of it. If it's true, that means we must use sha256.
43
43
  * @property {(string[]|null)} filenameExtFilter set of filename extension filters for use by upload agents to indicate to the user the acceptable filename patterns.
44
+ * @property {boolean} allowEmptyFile whether empty (0-byte) file uploads are allowed. Defaults to false.
44
45
  *
45
46
  * @desc
46
47
  * Constructor for AssetPseudoColumn.
@@ -73,6 +74,7 @@ export class AssetPseudoColumn extends ReferenceColumn {
73
74
  private _filenamePattern?: string;
74
75
  private _filenameExtFilter?: string[];
75
76
  private _filenameExtRegexp?: string[];
77
+ private _allowEmptyFile?: boolean;
76
78
  private _displayImagePreview?: boolean;
77
79
  private _filePreview?: FilePreviewConfig | null;
78
80
 
@@ -438,6 +440,17 @@ export class AssetPseudoColumn extends ReferenceColumn {
438
440
  return this._filenameExtRegexp;
439
441
  }
440
442
 
443
+ /**
444
+ * Whether empty (0-byte) file uploads are allowed for this asset column.
445
+ * Defaults to false, meaning empty files are rejected.
446
+ */
447
+ get allowEmptyFile(): boolean {
448
+ if (this._allowEmptyFile === undefined) {
449
+ this._allowEmptyFile = this._annotation.allow_empty_file === true;
450
+ }
451
+ return this._allowEmptyFile;
452
+ }
453
+
441
454
  /**
442
455
  * whether we should show the image preview or not
443
456
  */
@@ -68,6 +68,18 @@ export type ActiveListRelatedEntityRequest = {
68
68
  related?: boolean;
69
69
  /** the index of the related entity in the reference.related array */
70
70
  index: number;
71
+ /**
72
+ * the source hash (pseudo-column name) of the displayed entity set. Set so the
73
+ * consolidation pass can match data consumers (values, wait_fors, citation,
74
+ * condition sources) of the same source onto this display request.
75
+ */
76
+ entitysetSourceName?: string;
77
+ /**
78
+ * consumers folded onto this display request by the consolidation pass. When
79
+ * present, chaise routes the display read's page to these consumers instead of
80
+ * issuing a separate fetch. Mirrors `ActiveListRequest.objects`.
81
+ */
82
+ objects?: Array<ActiveListRequestObject>;
71
83
  };
72
84
 
73
85
  /**
@@ -333,7 +345,7 @@ export class ActiveListBuilder {
333
345
  */
334
346
  addInline(col: VisibleColumn, i: number): void {
335
347
  if (isRelatedColumn(col)) {
336
- this.requests.push({ inline: true, index: i });
348
+ this.requests.push({ inline: true, index: i, entitysetSourceName: col.name });
337
349
  } else {
338
350
  this.addCol(col, false, ActiveListRequestTypes.COLUMN, i);
339
351
  }
@@ -346,7 +358,7 @@ export class ActiveListBuilder {
346
358
  /** Same as addInline but adds to dependent requests in a conditional group */
347
359
  addInlineToDependent(dependentRequests: Array<ActiveListRequest | ActiveListRelatedEntityRequest>, col: VisibleColumn, i: number): void {
348
360
  if (isRelatedColumn(col)) {
349
- dependentRequests.push({ inline: true, index: i });
361
+ dependentRequests.push({ inline: true, index: i, entitysetSourceName: col.name });
350
362
  } else {
351
363
  this.addColToDependent(dependentRequests, col, false, ActiveListRequestTypes.COLUMN, i);
352
364
  }
@@ -423,10 +435,90 @@ export class ActiveListBuilder {
423
435
  this.allOutBounds.push(fkCol);
424
436
  }
425
437
 
438
+ /**
439
+ * consolidates the requests in this.requests and this.conditionalGroups.dependentRequests
440
+ * to avoid duplicate reads for the same source.
441
+ *
442
+ * - Uses similar dedup logic as `consideredSets` / `consideredAggregates`
443
+ * (group by source hash, first request wins, the rest attach their `objects`),
444
+ * plus one clause: a display (visible inline or related entities) wins when one exists,
445
+ * so a source that is both displayed and consumed is read once.
446
+ * - Runs as a final pass rather than an inline map because related displays are registered
447
+ * after the consumers that reference them, so at consumer time the display does not exist yet.
448
+ * - A fold removes the duplicate from wherever it lives (`requests` or a group's
449
+ * `dependentRequests`); a deferred merge target is promoted into `requests` when any
450
+ * request for its source is unconditional, so the read happens on load. Nothing is ever
451
+ * added to a group's `dependentRequests`.
452
+ */
453
+ private consolidateRequests(): void {
454
+ if (!this.isDetailed) return;
455
+
456
+ type LocatedRequest = {
457
+ requestList: Array<ActiveListRequest | ActiveListRelatedEntityRequest>;
458
+ req: ActiveListRequest & ActiveListRelatedEntityRequest;
459
+ isRelatedOrInline: boolean;
460
+ isUnconditional: boolean;
461
+ };
462
+
463
+ // capture all the requests that we have (conditioned + unconditioned).
464
+ // null-prototype so a schema-controlled source hash like `__proto__` is a plain key.
465
+ const requestsBySource: { [hash: string]: LocatedRequest[] } = Object.create(null);
466
+ const collect = (requestList: Array<ActiveListRequest | ActiveListRelatedEntityRequest>, isUnconditional: boolean) => {
467
+ requestList.forEach((r) => {
468
+ const req = r as ActiveListRequest & ActiveListRelatedEntityRequest;
469
+ const isRelatedOrInline = !!(req.inline || req.related);
470
+ const hash = isRelatedOrInline ? req.entitysetSourceName : req.column?.name;
471
+ if (typeof hash !== 'string') return;
472
+ if (!(hash in requestsBySource)) requestsBySource[hash] = [];
473
+ requestsBySource[hash].push({ requestList, req, isRelatedOrInline, isUnconditional });
474
+ });
475
+ };
476
+ collect(this.requests, true);
477
+ this.conditionalGroups.forEach((group) => collect(group.dependentRequests, false));
478
+
479
+ Object.keys(requestsBySource).forEach((hash) => {
480
+ const entries = requestsBySource[hash];
481
+ if (entries.length < 2) return;
482
+
483
+ /**
484
+ * Prefer a display as the merge target (even a conditional one): it renders the
485
+ * table AND yields the rows the data consumers need, so everyone reuses its read.
486
+ * A conditional display is promoted to run on load below when needed. With no
487
+ * display and no unconditional consumer there is no safe target, so skip: e.g.
488
+ * condition A gates column X and condition B gates column Y, both X and Y valued
489
+ * from the same entity set `H`. Folding B's `H` onto A would gate Y behind A, so
490
+ * if A is false but B is true, Y loses its data. Leave them as two reads.
491
+ */
492
+ const displays = entries.filter((e) => e.isRelatedOrInline);
493
+ const mergeTarget = displays.find((e) => e.isUnconditional) || displays[0] || entries.find((e) => e.isUnconditional);
494
+ if (!mergeTarget) return;
495
+
496
+ const anyUnconditional = entries.some((e) => e.isUnconditional);
497
+ entries.forEach((e) => {
498
+ if (e === mergeTarget) return;
499
+ // when the merge target is deferred, only fold unconditional requests or ones in
500
+ // the merge target's own list (never another group's dependent)
501
+ if (!mergeTarget.isUnconditional && !e.isUnconditional && e.requestList !== mergeTarget.requestList) return;
502
+ if (!mergeTarget.req.objects) mergeTarget.req.objects = [];
503
+ if (Array.isArray(e.req.objects)) mergeTarget.req.objects.push(...e.req.objects);
504
+ const idx = e.requestList.indexOf(e.req);
505
+ if (idx !== -1) e.requestList.splice(idx, 1);
506
+ });
507
+
508
+ // a deferred merge target that has an unconditional request must run on load.
509
+ if (!mergeTarget.isUnconditional && anyUnconditional) {
510
+ const idx = mergeTarget.requestList.indexOf(mergeTarget.req);
511
+ if (idx !== -1) mergeTarget.requestList.splice(idx, 1);
512
+ this.requests.push(mergeTarget.req);
513
+ }
514
+ });
515
+ }
516
+
426
517
  /**
427
518
  * Return the built ActiveList object. After calling this method, the builder should not be used anymore.
428
519
  */
429
520
  build(): ActiveList {
521
+ this.consolidateRequests();
430
522
  return {
431
523
  requests: this.requests,
432
524
  conditionalGroups: this.conditionalGroups,
@@ -647,6 +647,20 @@ export default class HandlebarsService {
647
647
  return Array.isArray(array) && array.includes(value);
648
648
  },
649
649
 
650
+ /**
651
+ * {{#if (overlaps arrayCol "a" "b")}}
652
+ * {{#if (overlaps arrayCol someArray)}}
653
+ * {{#if (overlaps arrayCol (pluck $self "values.type"))}}
654
+ *
655
+ * Variadic candidate args; any array argument is flattened one level.
656
+ * @returns true if `array` shares at least one element with the candidates
657
+ */
658
+ overlaps: function (array: unknown, ...args: unknown[]) {
659
+ if (!Array.isArray(array)) return false;
660
+ const candidates = flattenVariadicArgs(args);
661
+ return array.some((v) => candidates.includes(v));
662
+ },
663
+
650
664
  /**
651
665
  * {{pluck $self "values.type"}}
652
666
  *
@@ -298,6 +298,7 @@ export const _handlebarsHelpersList = [
298
298
  // list/array helpers
299
299
  'memberOf',
300
300
  'hasMember',
301
+ 'overlaps',
301
302
  'pluck',
302
303
  ];
303
304
 
@@ -60,6 +60,18 @@ export function buildSelfTemplateVariables(column: ReferenceColumn, mainTuple: T
60
60
  };
61
61
  }
62
62
 
63
+ // InboundForeignKeyPseudoColumn: a single-inbound-FK entity set. Like the
64
+ // isPathColumn entityset case, use the fetched page's templateVariables.
65
+ if ((column as any).isInboundForeignKey) {
66
+ if (columnValue && columnValue.templateVariables) {
67
+ if (Array.isArray(columnValue.templateVariables)) {
68
+ return { $self: columnValue.templateVariables };
69
+ }
70
+ return columnValue.templateVariables;
71
+ }
72
+ return {};
73
+ }
74
+
63
75
  // PseudoColumn (path-based source column)
64
76
  if ((column as any).isPathColumn) {
65
77
  const pseudoCol = column as PseudoColumn;
@@ -88,8 +100,14 @@ export function buildSelfTemplateVariables(column: ReferenceColumn, mainTuple: T
88
100
  };
89
101
  }
90
102
 
91
- // entityset: use templateVariables from the page result
103
+ // entityset vs aggregate value:
104
+ // - an entityset's value is the page, whose templateVariables is the bare array
105
+ // of per-row template variables -> wrap it as $self.
106
+ // - an aggregate value already carries { $self, $_self } (chaise builds it) -> use as-is.
92
107
  if (columnValue && columnValue.templateVariables) {
108
+ if (Array.isArray(columnValue.templateVariables)) {
109
+ return { $self: columnValue.templateVariables };
110
+ }
93
111
  return columnValue.templateVariables;
94
112
  }
95
113
 
package/tsconfig.json CHANGED
@@ -23,7 +23,8 @@
23
23
  "noUnusedLocals": false,
24
24
  "noUnusedParameters": false, // TODO change this back
25
25
  "noFallthroughCasesInSwitch": true,
26
- "noUncheckedSideEffectImports": true
26
+ "noUncheckedSideEffectImports": true,
27
+ "types": ["node"]
27
28
  },
28
29
  "include": ["src"]
29
30
  }