@proteinjs/db 1.37.0 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/db",
3
- "version": "1.37.0",
3
+ "version": "1.39.0",
4
4
  "main": "./dist/generated/index.js",
5
5
  "types": "./dist/generated/index.d.ts",
6
6
  "exports": {
@@ -41,7 +41,7 @@
41
41
  "test": "jest --passWithNoTests"
42
42
  },
43
43
  "dependencies": {
44
- "@proteinjs/db-query": "^1.8.0",
44
+ "@proteinjs/db-query": "^1.9.0",
45
45
  "@proteinjs/logger": "^1.0.21",
46
46
  "@proteinjs/reflection": "^1.2.0",
47
47
  "@proteinjs/serializer": "^1.1.10",
@@ -66,5 +66,5 @@
66
66
  "ts-jest": "29.1.1",
67
67
  "typescript": "5.2.2"
68
68
  },
69
- "gitHead": "cb0fda126d5d2bd5b17d1bc13e87c2373eb61f52"
69
+ "gitHead": "bc027111370f4391426dce2e8faf0b862664b3c5"
70
70
  }
package/src/Columns.ts CHANGED
@@ -27,14 +27,17 @@ export class StringColumn<T = string> implements Column<T, string> {
27
27
  public options?: ColumnOptions,
28
28
  public maxLength: number | 'MAX' = 255
29
29
  ) {
30
- this.options = Object.assign(
31
- {
32
- ui: {
33
- hidden: maxLength === 'MAX',
34
- },
35
- },
36
- options
37
- );
30
+ /**
31
+ * Unbounded ('MAX') text columns are NOT default-hidden (founder ruling, admin round 3):
32
+ * the record FORM renders any length safely now (multiline field with the bounded preview
33
+ * + expand dialog past the inline bound), and a value like a migration's failure stack is
34
+ * exactly what the form gets opened to read. The record TABLE's default column pick still
35
+ * excludes unbounded text (a row can't afford one) — that exclusion lives with the pick
36
+ * (db-ui `defaultRecordTableColumns`), keyed on `maxLength`, not here. `ui.hidden` remains
37
+ * the author's explicit hide-everywhere; subclasses whose storage happens to be MAX but
38
+ * whose values aren't prose (Object/Array/Password/Uuid) declare their own ui defaults.
39
+ */
40
+ this.options = Object.assign({ ui: {} }, options);
38
41
  }
39
42
  }
40
43
 
package/src/Db.ts CHANGED
@@ -58,6 +58,8 @@ export type DbDriverQueryStatementConfig = ParameterizationConfig & {
58
58
  prefixTablesWithDb?: boolean;
59
59
  getDriverColumnType?: (tableName: string, columnName: string) => string;
60
60
  handleCaseSensitivity: (tableName: string, columnName: string, caseSensitive: boolean) => string;
61
+ /** Driver SQL for truncating a datetime column to a bucket unit (`QueryBuilder.timeBucket`). */
62
+ dateTruncExpression?: (resolvedColumnName: string, unit: 'day' | 'hour') => string;
61
63
  };
62
64
 
63
65
  export type DbDriverDmlStatementConfig = ParameterizationConfig & {
@@ -708,6 +710,29 @@ export class Db<R extends Record = Record> implements DbService<R> {
708
710
  return result[0]['count'];
709
711
  }
710
712
 
713
+ /**
714
+ * Run a grouped-aggregation query and return the RAW result rows — one object per group,
715
+ * carrying the selected group fields (keyed by their DRIVER COLUMN names), any
716
+ * `timeBucket` dimension (keyed by its `resultProp`), and the aggregate values (keyed by
717
+ * their `resultProp`s). This is the scalable read for reporting rollups (SUM/COUNT per
718
+ * model/day/owner AT the database) — the alternative, `query()` + summing in memory, loads
719
+ * every underlying row and does not survive real data volumes.
720
+ *
721
+ * Rides the same authority as `query()`: table read auth unless run as system, and column
722
+ * queries (e.g. scope pre-filtering) are applied — a scoped caller aggregates only rows it
723
+ * could read. No record deserialization happens: aggregate rows are not records.
724
+ */
725
+ async queryAggregates<T extends R>(table: Table<T>, qb: QueryBuilder<T>): Promise<{ [key: string]: unknown }[]> {
726
+ if (!this.runAsSystem) {
727
+ this.auth.canQuery(table);
728
+ }
729
+
730
+ await this.addColumnQueries(table, qb);
731
+ const generateQuery = (config: DbDriverQueryStatementConfig) =>
732
+ qb.toSql(this.statementConfigFactory.getStatementConfig(config));
733
+ return await this.dbDriver.runQuery(generateQuery, this.transactionForDriver());
734
+ }
735
+
711
736
  private async addColumnQueries<T extends R>(
712
737
  table: Table<T>,
713
738
  qb: QueryBuilder<T>,
@@ -34,7 +34,10 @@ export class StatementConfigFactory {
34
34
  useNamedParams: config.useNamedParams,
35
35
  getColumnType: this.getColumnType(),
36
36
  getDriverColumnType: config.getDriverColumnType,
37
- ...(this.isQueryConfig(config) && { handleCaseSensitivity: config.handleCaseSensitivity }),
37
+ ...(this.isQueryConfig(config) && {
38
+ handleCaseSensitivity: config.handleCaseSensitivity,
39
+ dateTruncExpression: config.dateTruncExpression,
40
+ }),
38
41
  };
39
42
  }
40
43
 
package/src/Table.ts CHANGED
@@ -311,6 +311,15 @@ export type ColumnOptions = {
311
311
  ) => Promise<void>;
312
312
  ui?: {
313
313
  hidden?: boolean;
314
+ /**
315
+ * Which section of the record form this column belongs to. The form derives a sane
316
+ * section from the column's type and name (identity strings up top, long text and
317
+ * structured values under Content, everything else under Details, server-managed meta
318
+ * under System); this hint overrides the derivation. Known values map to the canonical
319
+ * sections ('identity' | 'content' | 'details' | 'system'); any other string becomes its
320
+ * own titled section, ordered after Details.
321
+ */
322
+ formGroup?: 'identity' | 'content' | 'details' | 'system' | (string & {});
314
323
  };
315
324
  };
316
325
 
@@ -27,7 +27,9 @@ export class DataEncryptionKeyTable extends Table<DataEncryptionKey> {
27
27
  public columns: Table<DataEncryptionKey>['columns'] = withRecordColumns<DataEncryptionKey>({
28
28
  owner: new StringColumn('owner', { nullable: false }, 36),
29
29
  version: new IntegerColumn('version', { nullable: false }),
30
- wrappedKey: new StringColumn('wrapped_key', { nullable: false }, 'MAX'),
30
+ // Explicitly ui-hidden: unbounded TEXT columns render on record forms now (admin round 3),
31
+ // but this is wrapped key MATERIAL, not prose — it never renders in any UI.
32
+ wrappedKey: new StringColumn('wrapped_key', { nullable: false, ui: { hidden: true } }, 'MAX'),
31
33
  status: new StringColumn('status', { nullable: false }, 16),
32
34
  });
33
35
  /** (owner, version) is the identity an envelope names — the race loser on concurrent
@@ -0,0 +1,42 @@
1
+ /**
2
+ * StringColumn's ui defaults (founder ruling, admin round 3): an unbounded ('MAX') plain-text
3
+ * column is NOT default-hidden anymore — the record form renders any length safely (bounded
4
+ * preview + expand past the inline bound), so the old there-is-no-safe-rendering hide is
5
+ * obsolete. The record TABLE's default pick excludes unbounded text at the pick itself
6
+ * (db-ui), not via this layer. `ui.hidden` remains the author's explicit hide-everywhere,
7
+ * and the columns whose MAX storage isn't prose keep their own explicit ui defaults.
8
+ */
9
+ // Table first: it anchors the src module cycle (Table → Record → Columns) the way every
10
+ // sibling suite does — importing Columns first evaluates Record before UuidColumn exists.
11
+ import '../src/Table';
12
+ import { ArrayColumn, ObjectColumn, PasswordColumn, StringColumn, UuidColumn } from '../src/Columns';
13
+
14
+ describe('StringColumn ui defaults', () => {
15
+ it('an unbounded column is not default-hidden', () => {
16
+ const column = new StringColumn('report', undefined, 'MAX');
17
+ expect(column.options?.ui?.hidden).toBeFalsy();
18
+ });
19
+
20
+ it('a bounded column stays not hidden', () => {
21
+ expect(new StringColumn('title').options?.ui?.hidden).toBeFalsy();
22
+ expect(new StringColumn('description', {}, 4000).options?.ui?.hidden).toBeFalsy();
23
+ });
24
+
25
+ it("an author's explicit ui.hidden survives on an unbounded column", () => {
26
+ const column = new StringColumn('transcript', { ui: { hidden: true } }, 'MAX');
27
+ expect(column.options?.ui?.hidden).toBe(true);
28
+ });
29
+
30
+ it('author options without ui still leave the column visible', () => {
31
+ const column = new StringColumn('notes', { defaultValue: async () => 'x' }, 'MAX');
32
+ expect(column.options?.ui?.hidden).toBeFalsy();
33
+ expect(column.options?.defaultValue).toBeDefined();
34
+ });
35
+
36
+ it('non-prose MAX-storage columns keep their own hidden defaults', () => {
37
+ expect(new ObjectColumn('payload').options?.ui?.hidden).toBe(true);
38
+ expect(new ArrayColumn('items').options?.ui?.hidden).toBe(true);
39
+ expect(new PasswordColumn('password').options?.ui?.hidden).toBe(true);
40
+ expect(new UuidColumn('token').options?.ui?.hidden).toBe(true);
41
+ });
42
+ });