@getstrata/core 2.2.25 → 2.2.26

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 2.2.26
4
+
5
+ - Integer validation checks complete safe values instead of accepting numeric prefixes. Whole signed decimal form strings, optional values and existing error messages remain supported; `allowNumericStrings: false` requires JSON numbers. Validated strings are not implicitly cast. Invalid range bounds reject at configuration time.
6
+ - `SqlOutbox.getDelivery(eventId, listenerName)` provides bounded read-only status/attempt inspection under the caller's active connection and RLS scope. It returns no payloads, owner tokens or provider errors, never establishes bypass, and remains an advisory snapshot distinct from atomic replay.
7
+ - Repository and fluent `sumBy(groupColumn, column)` return typed groups with database textual totals, preserving precision without implicit JavaScript number conversion. Bound/protected predicates, soft deletes and transaction/RLS scopes are preserved; unsupported transformations reject explicitly. Native Postgres, MySQL and SQLite regressions cover precision, null groups, rollback and overflow behavior.
8
+
3
9
  ## 2.2.25
4
10
 
5
11
  Queue worker run loops recover native Redis connection closure with bounded backoff and a fresh connection. Uncertain Redis mutations are not replayed; retained reservations and retries use normal recovery. Explicit closure remains terminal and non-connection errors remain fatal. Native poll/ack closure regressions cover Streams and list transports.
@@ -78,6 +78,11 @@ declare class BaseRepository<TEntity extends object, PrimaryKey extends keyof TE
78
78
  protected countWhere(where?: QueryWhere<TEntity>, options?: Pick<QueryOptions<TEntity>, "withTrashed" | "onlyTrashed" | "joins" | "groupBy">, whereNodes?: readonly WhereNode<TEntity>[]): Promise<number>;
79
79
  protected averageColumn(column: keyof TEntity & string, where?: QueryWhere<TEntity>): Promise<number>;
80
80
  protected averageExpression(expression: string, alias: string, where?: QueryWhere<TEntity>): Promise<number>;
81
+ /** One plain grouped projection; SQL numeric totals remain exact text, never implicit JS numbers. */
82
+ sumBy<K extends keyof TEntity & string>(groupColumn: K, column: keyof TEntity & string, options?: ExtendedQueryOptions<TEntity>): Promise<Array<{
83
+ value: TEntity[K] | null;
84
+ sum: string | null;
85
+ }>>;
81
86
  sum(column: keyof TEntity & string, where?: QueryWhere<TEntity>): Promise<number>;
82
87
  avg(column: keyof TEntity & string, where?: QueryWhere<TEntity>): Promise<number>;
83
88
  min(column: keyof TEntity & string, where?: QueryWhere<TEntity>): Promise<number>;
@@ -36,6 +36,11 @@ declare function buildProjectionQuery<TEntity extends object>(table: TableDefini
36
36
  params: unknown[];
37
37
  };
38
38
  declare function assertSafeProjectionExpression(expression: string): void;
39
+ /** Fixed aliases and textual totals avoid driver-dependent JavaScript precision loss. */
40
+ declare function buildGroupedSumQuery<TEntity extends object, K extends keyof TEntity & string>(table: TableDefinition<TEntity>, groupColumn: K, column: keyof TEntity & string, options?: QueryOptions<TEntity>, whereNodes?: readonly WhereNode<TEntity>[]): {
41
+ text: string;
42
+ params: unknown[];
43
+ };
39
44
  declare function buildGroupedCountQuery<TEntity extends object, K extends keyof TEntity & string>(table: TableDefinition<TEntity>, column: K, where?: QueryWhere<TEntity>, options?: Pick<QueryOptions<TEntity>, "withTrashed" | "onlyTrashed">): {
40
45
  text: string;
41
46
  params: unknown[];
@@ -73,4 +78,4 @@ declare function buildConditionalWriteQuery<TEntity extends object, PrimaryKey e
73
78
  text: string;
74
79
  params: unknown[];
75
80
  };
76
- export { assertSafeProjectionExpression, buildAdvancedWhereClause, buildConditionalWriteQuery, buildCountQuery, buildDeleteByIdQuery, buildGroupedCountQuery, buildIncrementQuery, buildInsertQuery, buildJoinClause, buildOrderByClause, buildProjectionQuery, buildQueryWhereClause, buildRestoreByIdQuery, buildSelectQuery, buildSoftDeleteByIdQuery, buildUpdateQuery, buildUpsertQuery, buildWhereClause, parseQualifiedColumn, qualifyColumn, quoteIdentifier, resolveQualifiedColumn, resolveSoftDeleteColumn, };
81
+ export { assertSafeProjectionExpression, buildAdvancedWhereClause, buildConditionalWriteQuery, buildCountQuery, buildDeleteByIdQuery, buildGroupedCountQuery, buildGroupedSumQuery, buildIncrementQuery, buildInsertQuery, buildJoinClause, buildOrderByClause, buildProjectionQuery, buildQueryWhereClause, buildRestoreByIdQuery, buildSelectQuery, buildSoftDeleteByIdQuery, buildUpdateQuery, buildUpsertQuery, buildWhereClause, parseQualifiedColumn, qualifyColumn, quoteIdentifier, resolveQualifiedColumn, resolveSoftDeleteColumn, };
@@ -47,6 +47,10 @@ declare class RepositoryQuery<TEntity extends object, PrimaryKey extends keyof T
47
47
  private assertNoMutationEagerLoads;
48
48
  get(): Promise<Array<TEntity & LoadedRow>>;
49
49
  first(): Promise<(TEntity & LoadedRow) | null>;
50
+ sumBy<K extends keyof TEntity & string>(groupColumn: K, column: keyof TEntity & string): Promise<Array<{
51
+ value: TEntity[K] | null;
52
+ sum: string | null;
53
+ }>>;
50
54
  count(): Promise<number>;
51
55
  pluck<K extends keyof TEntity & string>(column: K): Promise<Array<TEntity[K]>>;
52
56
  pluck<K extends keyof TEntity & string, KK extends keyof TEntity & string>(column: K, keyBy: KK): Promise<Map<TEntity[KK], TEntity[K]>>;
@@ -13,6 +13,12 @@ interface DurableListener {
13
13
  signal: AbortSignal;
14
14
  }): Promise<void>;
15
15
  }
16
+ type OutboxDeliveryStatus = "pending" | "processing" | "failed" | "completed";
17
+ interface OutboxDeliverySnapshot {
18
+ readonly status: OutboxDeliveryStatus;
19
+ readonly attempts: number;
20
+ readonly maxAttempts: number;
21
+ }
16
22
  interface SqlOutboxOptions {
17
23
  listeners: readonly DurableListener[];
18
24
  leaseMs?: number;
@@ -31,6 +37,8 @@ declare class SqlOutbox {
31
37
  private readonly tenantResolver;
32
38
  constructor(options: SqlOutboxOptions);
33
39
  hasListener(name: string, event?: string): boolean;
40
+ /** Read-only advisory observation: caller owns tenant/RLS scope; no implicit bypass. */
41
+ getDelivery(eventId: string, listenerName: string): Promise<OutboxDeliverySnapshot | null>;
34
42
  publish(name: string, payload: unknown, options?: {
35
43
  id?: string;
36
44
  version?: number;
@@ -53,5 +61,5 @@ declare class SqlOutbox {
53
61
  export type { OutboxMetricState, OutboxMetricsCollector, OutboxMetricsOptions, OutboxMetricsSnapshot, } from "./metrics";
54
62
  export { createOutboxMetricsCollector, renderOutboxMetrics } from "./metrics";
55
63
  export { createOutboxMigration } from "./migration";
56
- export type { DurableEvent, DurableListener, SqlOutboxOptions };
64
+ export type { DurableEvent, DurableListener, OutboxDeliverySnapshot, OutboxDeliveryStatus, SqlOutboxOptions, };
57
65
  export { SqlOutbox };
@@ -7,11 +7,15 @@ declare function maxLength(maximum: number): ValidationRule;
7
7
  declare function pattern(expression: RegExp): ValidationRule;
8
8
  declare function enumRule<TValue extends string>(allowedValues: readonly TValue[]): ValidationRule;
9
9
  declare function optional(): ValidationRule;
10
- declare function integerRule(): ValidationRule;
10
+ interface IntegerValidationOptions {
11
+ /** Default true preserves whole decimal form strings; false requires JSON numbers. */
12
+ allowNumericStrings?: boolean;
13
+ }
14
+ declare function integerRule(options?: IntegerValidationOptions): ValidationRule;
11
15
  declare function emailRule(): ValidationRule;
12
16
  declare function confirmed(fieldName: string): ValidationRule;
13
- declare function positiveIntegerRule(): ValidationRule;
14
- declare function integerRange(minimum: number, maximum: number): ValidationRule;
17
+ declare function positiveIntegerRule(options?: IntegerValidationOptions): ValidationRule;
18
+ declare function integerRange(minimum: number, maximum: number, options?: IntegerValidationOptions): ValidationRule;
15
19
  declare function validateObject(payload: unknown, schema: ValidationSchema): Record<string, unknown>;
16
- export type { ValidationRule, ValidationSchema };
20
+ export type { IntegerValidationOptions, ValidationRule, ValidationSchema };
17
21
  export { confirmed, emailRule, enumRule, integerRange, integerRule, maxLength, minLength, optional, pattern, positiveIntegerRule, required, stringRule, validateObject, };
@@ -429,6 +429,20 @@ function assertSafeProjectionExpression(expression) {
429
429
  throw new Error(`Unsafe projection expression: ${expression}`);
430
430
  }
431
431
  }
432
+ function buildGroupedSumQuery(table, groupColumn, column, options = {}, whereNodes = options.whereNodes ?? []) {
433
+ if (!table.columns.includes(groupColumn) || !table.columns.includes(column))
434
+ throw new TypeError("Grouped sums require declared columns.");
435
+ for (const key of Object.keys(options))
436
+ if (!["where", "whereNodes", "withTrashed", "onlyTrashed"].includes(key))
437
+ throw new TypeError("Grouped sums do not support joins, selection, ordering, pagination, grouping or locks.");
438
+ const group = qualifyColumn(table.name, groupColumn);
439
+ const total = currentSqlDialect().castToText(`SUM(${qualifyColumn(table.name, column)})`);
440
+ const { clause, params } = buildQueryWhereClause(table, options, whereNodes);
441
+ return {
442
+ text: `SELECT ${group} AS ${quoteIdentifier("value")}, ${total} AS ${quoteIdentifier("sum")} FROM ${quoteIdentifier(table.name)}${clause} GROUP BY ${group} ORDER BY ${group} ASC`,
443
+ params
444
+ };
445
+ }
432
446
  function buildGroupedCountQuery(table, column, where = {}, options = {}) {
433
447
  const qualifiedColumn = qualifyColumn(table.name, column);
434
448
  const { clause, params } = buildQueryWhereClause(table, {
@@ -608,6 +622,7 @@ export {
608
622
  buildCountQuery,
609
623
  buildDeleteByIdQuery,
610
624
  buildGroupedCountQuery,
625
+ buildGroupedSumQuery,
611
626
  buildIncrementQuery,
612
627
  buildInsertQuery,
613
628
  buildJoinClause,
@@ -453,6 +453,20 @@ function assertSafeProjectionExpression(expression) {
453
453
  throw new Error(`Unsafe projection expression: ${expression}`);
454
454
  }
455
455
  }
456
+ function buildGroupedSumQuery(table, groupColumn, column, options = {}, whereNodes = options.whereNodes ?? []) {
457
+ if (!table.columns.includes(groupColumn) || !table.columns.includes(column))
458
+ throw new TypeError("Grouped sums require declared columns.");
459
+ for (const key of Object.keys(options))
460
+ if (!["where", "whereNodes", "withTrashed", "onlyTrashed"].includes(key))
461
+ throw new TypeError("Grouped sums do not support joins, selection, ordering, pagination, grouping or locks.");
462
+ const group = qualifyColumn(table.name, groupColumn);
463
+ const total = currentSqlDialect().castToText(`SUM(${qualifyColumn(table.name, column)})`);
464
+ const { clause, params } = buildQueryWhereClause(table, options, whereNodes);
465
+ return {
466
+ text: `SELECT ${group} AS ${quoteIdentifier("value")}, ${total} AS ${quoteIdentifier("sum")} FROM ${quoteIdentifier(table.name)}${clause} GROUP BY ${group} ORDER BY ${group} ASC`,
467
+ params
468
+ };
469
+ }
456
470
  function buildGroupedCountQuery(table, column, where = {}, options = {}) {
457
471
  const qualifiedColumn = qualifyColumn(table.name, column);
458
472
  const { clause, params } = buildQueryWhereClause(table, {
@@ -1124,6 +1138,11 @@ class RepositoryQuery {
1124
1138
  const attached = await this.attach(rows);
1125
1139
  return attached[0] ?? null;
1126
1140
  }
1141
+ async sumBy(groupColumn, column) {
1142
+ if (this.eagerLoads.length)
1143
+ throw new TypeError("Grouped sums do not support eager loading.");
1144
+ return this.repository.sumBy(groupColumn, column, this.buildOptions());
1145
+ }
1127
1146
  async count() {
1128
1147
  return await this.repository.count(this.buildOptions());
1129
1148
  }
@@ -69,15 +69,21 @@ function optional() {
69
69
  return;
70
70
  };
71
71
  }
72
- function integerRule() {
72
+ function integerValue(value, options) {
73
+ if (typeof value === "number")
74
+ return Number.isSafeInteger(value) ? value : undefined;
75
+ if (options.allowNumericStrings !== false && typeof value === "string" && /^[+-]?\d+$/.test(value.trim())) {
76
+ const parsed = Number(value.trim());
77
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
78
+ }
79
+ return;
80
+ }
81
+ function integerRule(options = {}) {
73
82
  return (field, value) => {
74
- if (value === undefined || value === null || value === "") {
83
+ if (value === undefined || value === null || value === "")
75
84
  return;
76
- }
77
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
78
- if (!Number.isInteger(parsed)) {
85
+ if (integerValue(value, options) === undefined)
79
86
  return `"${field}" must be an integer.`;
80
- }
81
87
  return;
82
88
  };
83
89
  }
@@ -103,27 +109,25 @@ function confirmed(fieldName) {
103
109
  return;
104
110
  };
105
111
  }
106
- function positiveIntegerRule() {
112
+ function positiveIntegerRule(options = {}) {
107
113
  return (field, value) => {
108
- if (value === undefined || value === null || value === "") {
114
+ if (value === undefined || value === null || value === "")
109
115
  return;
110
- }
111
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
112
- if (!Number.isInteger(parsed) || parsed <= 0) {
116
+ const parsed = integerValue(value, options);
117
+ if (parsed === undefined || parsed <= 0)
113
118
  return `"${field}" must be a positive integer.`;
114
- }
115
119
  return;
116
120
  };
117
121
  }
118
- function integerRange(minimum, maximum) {
122
+ function integerRange(minimum, maximum, options = {}) {
123
+ if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || minimum > maximum)
124
+ throw new TypeError("Integer range bounds must be safe integers in ascending order.");
119
125
  return (field, value) => {
120
- if (value === undefined || value === null || value === "") {
126
+ if (value === undefined || value === null || value === "")
121
127
  return;
122
- }
123
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
124
- if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
128
+ const parsed = integerValue(value, options);
129
+ if (parsed === undefined || parsed < minimum || parsed > maximum)
125
130
  return `"${field}" must be an integer between ${minimum} and ${maximum}.`;
126
- }
127
131
  return;
128
132
  };
129
133
  }
package/dist/index.js CHANGED
@@ -4925,6 +4925,20 @@ function assertSafeProjectionExpression(expression) {
4925
4925
  throw new Error(`Unsafe projection expression: ${expression}`);
4926
4926
  }
4927
4927
  }
4928
+ function buildGroupedSumQuery(table, groupColumn, column, options = {}, whereNodes = options.whereNodes ?? []) {
4929
+ if (!table.columns.includes(groupColumn) || !table.columns.includes(column))
4930
+ throw new TypeError("Grouped sums require declared columns.");
4931
+ for (const key of Object.keys(options))
4932
+ if (!["where", "whereNodes", "withTrashed", "onlyTrashed"].includes(key))
4933
+ throw new TypeError("Grouped sums do not support joins, selection, ordering, pagination, grouping or locks.");
4934
+ const group = qualifyColumn(table.name, groupColumn);
4935
+ const total = currentSqlDialect().castToText(`SUM(${qualifyColumn(table.name, column)})`);
4936
+ const { clause, params } = buildQueryWhereClause(table, options, whereNodes);
4937
+ return {
4938
+ text: `SELECT ${group} AS ${quoteIdentifier("value")}, ${total} AS ${quoteIdentifier("sum")} FROM ${quoteIdentifier(table.name)}${clause} GROUP BY ${group} ORDER BY ${group} ASC`,
4939
+ params
4940
+ };
4941
+ }
4928
4942
  function buildGroupedCountQuery(table, column, where = {}, options = {}) {
4929
4943
  const qualifiedColumn = qualifyColumn(table.name, column);
4930
4944
  const { clause, params } = buildQueryWhereClause(table, {
@@ -5623,6 +5637,11 @@ class RepositoryQuery {
5623
5637
  const attached = await this.attach(rows);
5624
5638
  return attached[0] ?? null;
5625
5639
  }
5640
+ async sumBy(groupColumn, column) {
5641
+ if (this.eagerLoads.length)
5642
+ throw new TypeError("Grouped sums do not support eager loading.");
5643
+ return this.repository.sumBy(groupColumn, column, this.buildOptions());
5644
+ }
5626
5645
  async count() {
5627
5646
  return await this.repository.count(this.buildOptions());
5628
5647
  }
@@ -6235,6 +6254,11 @@ class BaseRepository {
6235
6254
  const [row] = await this.connection.unsafe(text, params);
6236
6255
  return Number(row?.[alias] ?? 0);
6237
6256
  }
6257
+ async sumBy(groupColumn, column, options = {}) {
6258
+ const { whereNodes, ...queryOptions } = options;
6259
+ const { text, params } = buildGroupedSumQuery(this.table, groupColumn, column, queryOptions, whereNodes ?? []);
6260
+ return withDatabaseErrorHandling(() => this.connection.unsafe(text, params));
6261
+ }
6238
6262
  async sum(column, where = {}) {
6239
6263
  return await this.aggregateColumn("SUM", column, where);
6240
6264
  }
@@ -10446,6 +10470,22 @@ class SqlOutbox {
10446
10470
  const listener = this.listeners.get(name);
10447
10471
  return listener !== undefined && (event === undefined || listener.event === event);
10448
10472
  }
10473
+ async getDelivery(eventId, listenerName) {
10474
+ identifier(eventId, 64);
10475
+ identifier(listenerName, 128);
10476
+ const p = placeholders(2);
10477
+ const [row] = await repositoryConnection.unsafe(`SELECT status, attempts, max_attempts FROM strata_outbox_delivery WHERE event_id = ${p[0]} AND listener_name = ${p[1]} LIMIT 1`, [eventId, listenerName]);
10478
+ if (!row)
10479
+ return null;
10480
+ const attempts = Number(row.attempts);
10481
+ const maxAttempts = Number(row.max_attempts);
10482
+ if (!Number.isSafeInteger(attempts) || attempts < 0 || !Number.isSafeInteger(maxAttempts) || maxAttempts < 1)
10483
+ throw new Error("Invalid outbox delivery snapshot.");
10484
+ const status = row.status;
10485
+ if (status !== "pending" && status !== "processing" && status !== "failed" && status !== "completed")
10486
+ throw new Error("Invalid outbox delivery snapshot.");
10487
+ return { status, attempts, maxAttempts };
10488
+ }
10449
10489
  async publish(name, payload, options = {}) {
10450
10490
  if (!hasActiveTransaction())
10451
10491
  throw new Error("Durable publication requires an active business transaction.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/core",
3
- "version": "2.2.25",
3
+ "version": "2.2.26",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",