@tstdl/base 0.93.85 → 0.93.87

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.
@@ -84,10 +84,10 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
84
84
  */
85
85
  get definedTenantId(): string;
86
86
  /**
87
- * Get current subject or throw if not available
87
+ * Get current subjectId or throw if not available
88
88
  * @throws Will throw if subject is not available
89
89
  */
90
- get definedSubject(): string;
90
+ get definedSubjectId(): string;
91
91
  /** Whether a valid token is available (not undefined and not expired) */
92
92
  get hasValidToken(): boolean;
93
93
  constructor();
@@ -140,10 +140,10 @@ let AuthenticationClientService = class AuthenticationClientService {
140
140
  return this.definedToken.tenant;
141
141
  }
142
142
  /**
143
- * Get current subject or throw if not available
143
+ * Get current subjectId or throw if not available
144
144
  * @throws Will throw if subject is not available
145
145
  */
146
- get definedSubject() {
146
+ get definedSubjectId() {
147
147
  return this.definedToken.subject;
148
148
  }
149
149
  /** Whether a valid token is available (not undefined and not expired) */
@@ -1,13 +1,10 @@
1
1
  import { TenantEntity, type Uuid } from '../../orm/index.js';
2
2
  export declare class AuthenticationCredentials extends TenantEntity {
3
3
  subjectId: Uuid;
4
+ /** The version of the hash algorithm used. */
4
5
  hashVersion: number;
5
- /**
6
- * The salt used to hash the secret.
7
- */
6
+ /** The salt used to hash the secret. */
8
7
  salt: Uint8Array<ArrayBuffer>;
9
- /**
10
- * The hashed secret.
11
- */
8
+ /** The hashed secret. */
12
9
  hash: Uint8Array<ArrayBuffer>;
13
10
  }
@@ -12,14 +12,11 @@ import { Integer, Uint8ArrayProperty } from '../../schema/index.js';
12
12
  import { Subject } from './subject.model.js';
13
13
  let AuthenticationCredentials = class AuthenticationCredentials extends TenantEntity {
14
14
  subjectId;
15
+ /** The version of the hash algorithm used. */
15
16
  hashVersion;
16
- /**
17
- * The salt used to hash the secret.
18
- */
17
+ /** The salt used to hash the secret. */
19
18
  salt;
20
- /**
21
- * The hashed secret.
22
- */
19
+ /** The hashed secret. */
23
20
  hash;
24
21
  };
25
22
  __decorate([
@@ -4,13 +4,10 @@ export declare class AuthenticationSession extends TenantEntity {
4
4
  subjectId: Uuid;
5
5
  begin: Timestamp;
6
6
  end: Timestamp;
7
+ /** The version of the hash algorithm used. */
7
8
  refreshTokenHashVersion: number;
8
- /**
9
- * The salt used to hash the refresh token.
10
- */
9
+ /** The salt used to hash the refresh token. */
11
10
  refreshTokenSalt: Uint8Array<ArrayBuffer>;
12
- /**
13
- * The hashed refresh token.
14
- */
11
+ /** The hashed refresh token. */
15
12
  refreshTokenHash: Uint8Array<ArrayBuffer>;
16
13
  }
@@ -14,14 +14,11 @@ let AuthenticationSession = class AuthenticationSession extends TenantEntity {
14
14
  subjectId;
15
15
  begin;
16
16
  end;
17
+ /** The version of the hash algorithm used. */
17
18
  refreshTokenHashVersion;
18
- /**
19
- * The salt used to hash the refresh token.
20
- */
19
+ /** The salt used to hash the refresh token. */
21
20
  refreshTokenSalt;
22
- /**
23
- * The hashed refresh token.
24
- */
21
+ /** The hashed refresh token. */
25
22
  refreshTokenHash;
26
23
  };
27
24
  __decorate([
@@ -2,9 +2,7 @@ import type { Record } from '../../types/index.js';
2
2
  import type { JwtToken, JwtTokenHeader } from '../../utils/jwt.js';
3
3
  import type { TokenPayloadBase } from './token-payload-base.model.js';
4
4
  export type TokenHeader = {
5
- /**
6
- * Token version.
7
- */
5
+ /** Token version. */
8
6
  v: number;
9
7
  };
10
8
  /**
@@ -18,42 +16,26 @@ export type Token<AdditionalTokenPayload extends Record = Record<never>> = JwtTo
18
16
  */
19
17
  export type TokenPayload<T extends Record = Record<never>> = T & TokenPayloadBase;
20
18
  export type RefreshToken = JwtToken<{
21
- /**
22
- * Expiration timestamp in seconds.
23
- */
19
+ /** Expiration timestamp in seconds. */
24
20
  exp: number;
25
- /**
26
- * The tenant id.
27
- */
21
+ /** The tenant id. */
28
22
  tenant: string;
29
- /**
30
- * The subject of the token.
31
- */
23
+ /** The subject of the token. */
32
24
  subject: string;
33
- /**
34
- * The subject of the impersonator, if any.
35
- */
25
+ /** The subject of the impersonator, if any. */
36
26
  impersonator?: string;
37
- /**
38
- * The id of the session.
39
- */
27
+ /** The id of the session. */
40
28
  session: string;
41
- /**
42
- * The secret to use for refreshing the token.
43
- */
29
+ /** The secret to use for refreshing the token. */
44
30
  secret: string;
45
31
  }>;
46
32
  export type SecretResetToken = JwtToken<{
47
- /**
48
- * Expiration timestamp in seconds.
49
- */
33
+ /** Issued at timestamp in seconds. */
34
+ iat: number;
35
+ /** Expiration timestamp in seconds. */
50
36
  exp: number;
51
- /**
52
- * The tenant id.
53
- */
37
+ /** The tenant id. */
54
38
  tenant: string;
55
- /**
56
- * The subject for which to reset the secret.
57
- */
39
+ /** The subject for which to reset the secret. */
58
40
  subject: string;
59
41
  }>;
@@ -15,6 +15,7 @@ export type AuthenticationAuditEvents = {
15
15
  };
16
16
  'refresh-failure': {
17
17
  reason: string;
18
+ sessionId: string | null;
18
19
  };
19
20
  'impersonate-success': {
20
21
  impersonatedSubjectId: string;
@@ -25,7 +25,7 @@ import { currentTimestamp, timestampToTimestampSeconds } from '../../utils/date-
25
25
  import { timingSafeBinaryEquals } from '../../utils/equals.js';
26
26
  import { createJwtTokenString } from '../../utils/jwt.js';
27
27
  import { getRandomBytes, getRandomString } from '../../utils/random.js';
28
- import { assertDefinedPass, isBinaryData, isString, isUndefined } from '../../utils/type-guards.js';
28
+ import { isBinaryData, isDefined, isString, isUndefined } from '../../utils/type-guards.js';
29
29
  import { millisecondsPerDay, millisecondsPerMinute } from '../../utils/units.js';
30
30
  import { AuthenticationCredentials, AuthenticationSession, Subject, User } from '../models/index.js';
31
31
  import { AuthenticationAncillaryService, GetTokenPayloadContextAction } from './authentication-ancillary.service.js';
@@ -63,6 +63,13 @@ export class AuthenticationServiceOptions {
63
63
  */
64
64
  secretResetTokenTimeToLive;
65
65
  }
66
+ const HASH_ITERATIONS = 250000;
67
+ const HASH_LENGTH_BITS = 512;
68
+ const HASH_LENGTH_BYTES = HASH_LENGTH_BITS / 8;
69
+ const JWT_ID_LENGTH = 24;
70
+ const REFRESH_TOKEN_SECRET_LENGTH = 64;
71
+ const SALT_LENGTH = 32;
72
+ const SIGNING_SECRETS_DERIVATION_ITERATIONS = 500000;
66
73
  const SIGNING_SECRETS_LENGTH = 64;
67
74
  /**
68
75
  * Handles authentication on server side.
@@ -136,7 +143,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
136
143
  if (options?.skipValidation != true) {
137
144
  await this.#authenticationSecretRequirementsValidator.validateSecretRequirements(secret);
138
145
  }
139
- const salt = getRandomBytes(32);
146
+ const salt = getRandomBytes(SALT_LENGTH);
140
147
  const hash = await this.getHash(secret, salt);
141
148
  await this.#credentialsRepository.transaction(async (tx) => {
142
149
  await this.#credentialsRepository.withTransaction(tx).upsert(['tenantId', 'subjectId'], {
@@ -159,15 +166,18 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
159
166
  */
160
167
  async authenticate(subject, secret) {
161
168
  const actualSubject = await this.tryResolveSubject(subject);
162
- // Always try to load credentials, even if the subject is not resolved, to reduce information leakage.
163
- // If the subject is not resolved, we will create a new credentials entry with an empty salt and hash.
164
- // This way, we do not leak if the subject exists or not via timing attacks.
165
- const credentials = await this.#credentialsRepository.tryLoadByQuery({ tenantId: actualSubject?.tenantId ?? NIL_UUID, subjectId: actualSubject?.id ?? NIL_UUID })
166
- ?? { subjectId: actualSubject, salt: new Uint8Array(), hash: new Uint8Array() };
169
+ // we use a random uuid instead of null here to reduce timing attack surface by DB optimiziations
170
+ const queryTenantId = actualSubject?.tenantId ?? crypto.randomUUID();
171
+ const querySubjectId = actualSubject?.id ?? crypto.randomUUID();
172
+ const loadedCredentials = await this.#credentialsRepository.tryLoadByQuery({
173
+ tenantId: queryTenantId,
174
+ subjectId: querySubjectId,
175
+ });
176
+ const credentials = loadedCredentials ?? { salt: new Uint8Array(SALT_LENGTH), hash: new Uint8Array(HASH_LENGTH_BYTES) };
167
177
  const hash = await this.getHash(secret, credentials.salt);
168
178
  const valid = timingSafeBinaryEquals(hash, credentials.hash);
169
- if (valid) {
170
- return { success: true, subject: assertDefinedPass(actualSubject, 'Subject should be defined if authentication is valid but it is not.') };
179
+ if (valid && isDefined(actualSubject) && isDefined(loadedCredentials)) {
180
+ return { success: true, subject: actualSubject };
171
181
  }
172
182
  return { success: false };
173
183
  }
@@ -282,6 +292,12 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
282
292
  throw new InvalidTokenError('Session is expired.');
283
293
  }
284
294
  if (!timingSafeBinaryEquals(hash, session.refreshTokenHash)) {
295
+ await this.endSession(sessionId, auditor);
296
+ await authAuditor.warn('refresh-failure', {
297
+ targetId: session.tenantId,
298
+ targetType: 'User',
299
+ details: { sessionId, reason: 'Token reuse detected. Session revoked.' },
300
+ });
285
301
  throw new InvalidTokenError('Invalid refresh token.');
286
302
  }
287
303
  const now = currentTimestamp();
@@ -311,7 +327,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
311
327
  await authAuditor.warn('refresh-failure', {
312
328
  targetId: NIL_UUID,
313
329
  targetType: 'User',
314
- details: { reason: error.message },
330
+ details: { sessionId: null, reason: error.message },
315
331
  });
316
332
  throw error;
317
333
  }
@@ -458,6 +474,21 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
458
474
  const authAuditor = auditor.fork(AuthenticationService_1.name);
459
475
  try {
460
476
  const token = await this.validateSecretResetToken(tokenString);
477
+ const credentials = await this.#credentialsRepository.tryLoadByQuery({
478
+ tenantId: token.payload.tenant,
479
+ subjectId: token.payload.subject,
480
+ });
481
+ if (isDefined(credentials)) {
482
+ const lastUpdateSeconds = timestampToTimestampSeconds(credentials.metadata.revisionTimestamp);
483
+ if (token.payload.iat < lastUpdateSeconds) {
484
+ await authAuditor.info('reset-secret-failure', {
485
+ targetId: token.payload.subject,
486
+ targetType: 'User',
487
+ details: { reason: 'Token is invalid (credentials have already been changed).' },
488
+ });
489
+ throw new InvalidTokenError();
490
+ }
491
+ }
461
492
  const subject = await this.#subjectRepository.loadByQuery({ tenantId: token.payload.tenant, id: token.payload.subject });
462
493
  await this.setCredentials(subject, newSecret);
463
494
  await authAuditor.info('reset-secret-success', {
@@ -580,7 +611,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
580
611
  typ: 'JWT',
581
612
  };
582
613
  const payload = {
583
- jti: jwtId ?? getRandomString(24, Alphabet.LowerUpperCaseNumbers),
614
+ jti: jwtId ?? getRandomString(JWT_ID_LENGTH, Alphabet.LowerUpperCaseNumbers),
584
615
  iat: issuedAt ?? timestampToTimestampSeconds(timestamp),
585
616
  exp: expiration ?? timestampToTimestampSeconds(timestamp + this.tokenTimeToLive),
586
617
  refreshTokenExp: timestampToTimestampSeconds(refreshTokenExpiration),
@@ -607,8 +638,8 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
607
638
  * @returns The created refresh token.
608
639
  */
609
640
  async createRefreshToken(subject, sessionId, expirationTimestamp, options) {
610
- const secret = getRandomString(64, Alphabet.LowerUpperCaseNumbers);
611
- const salt = getRandomBytes(32);
641
+ const secret = getRandomString(REFRESH_TOKEN_SECRET_LENGTH, Alphabet.LowerUpperCaseNumbers);
642
+ const salt = getRandomBytes(SALT_LENGTH);
612
643
  const hash = await this.getHash(secret, salt);
613
644
  const jsonToken = {
614
645
  header: {
@@ -647,12 +678,14 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
647
678
  return subjects[0];
648
679
  }
649
680
  async createSecretResetToken(subject, expirationTimestamp) {
681
+ const iat = timestampToTimestampSeconds(currentTimestamp());
650
682
  const jsonToken = {
651
683
  header: {
652
684
  alg: 'HS256',
653
685
  typ: 'JWT',
654
686
  },
655
687
  payload: {
688
+ iat,
656
689
  exp: timestampToTimestampSeconds(expirationTimestamp),
657
690
  subject: subject.id,
658
691
  tenant: subject.tenantId,
@@ -663,9 +696,9 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
663
696
  }
664
697
  async deriveSigningSecrets(secret) {
665
698
  const key = await importPbkdf2Key(secret);
666
- const saltBase64 = await this.#keyValueStore.getOrSet('derivationSalt', encodeBase64(getRandomBytes(32)));
699
+ const saltBase64 = await this.#keyValueStore.getOrSet('derivationSalt', encodeBase64(getRandomBytes(SALT_LENGTH)));
667
700
  const salt = decodeBase64(saltBase64);
668
- const algorithm = { name: 'PBKDF2', hash: 'SHA-512', iterations: 500000, salt };
701
+ const algorithm = { name: 'PBKDF2', hash: 'SHA-512', iterations: SIGNING_SECRETS_DERIVATION_ITERATIONS, salt };
669
702
  const [derivedTokenSigningSecret, derivedRefreshTokenSigningSecret, derivedSecretResetTokenSigningSecret] = await deriveBytesMultiple(algorithm, key, 3, SIGNING_SECRETS_LENGTH);
670
703
  this.derivedTokenSigningSecret = derivedTokenSigningSecret;
671
704
  this.derivedRefreshTokenSigningSecret = derivedRefreshTokenSigningSecret;
@@ -673,7 +706,7 @@ let AuthenticationService = AuthenticationService_1 = class AuthenticationServic
673
706
  }
674
707
  async getHash(secret, salt) {
675
708
  const key = await importPbkdf2Key(secret);
676
- const hash = await globalThis.crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-512', iterations: 250000, salt }, key, 512);
709
+ const hash = await globalThis.crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-512', iterations: HASH_ITERATIONS, salt }, key, HASH_LENGTH_BITS);
677
710
  return new Uint8Array(hash);
678
711
  }
679
712
  };
@@ -28,6 +28,7 @@ import { configurePostgresQueue, migratePostgresQueueSchema } from '../../queue/
28
28
  import { configureDefaultSignalsImplementation } from '../../signals/implementation/configure.js';
29
29
  import { boolean, positiveInteger, string } from '../../utils/config-parser.js';
30
30
  import { TstdlCategoryParents, TstdlDocumentCategoryLabels, TstdlDocumentPropertyConfiguration, TstdlDocumentTypeCategories, TstdlDocumentTypeLabels, TstdlDocumentTypeProperties } from './categories-and-types.js';
31
+ import { configurePostgresCircuitBreaker, migratePostgresCircuitBreaker } from '../../circuit-breaker/postgres/module.js';
31
32
  const config = {
32
33
  database: {
33
34
  host: string('DATABASE_HOST', '127.0.0.1'),
@@ -87,6 +88,7 @@ async function bootstrap() {
87
88
  const injector = inject(Injector);
88
89
  configureNodeHttpServer();
89
90
  configurePostgresQueue();
91
+ configurePostgresCircuitBreaker();
90
92
  configureLocalMessageBus();
91
93
  configureDefaultSignalsImplementation();
92
94
  configureGenkit({
@@ -134,8 +136,9 @@ async function bootstrap() {
134
136
  apiKey: config.ai.apiKey,
135
137
  keyFile: config.ai.keyFile,
136
138
  });
137
- await runInInjectionContext(injector, async () => await migrateDocumentManagementSchema());
138
- await runInInjectionContext(injector, async () => await migratePostgresQueueSchema());
139
+ await runInInjectionContext(injector, migrateDocumentManagementSchema);
140
+ await runInInjectionContext(injector, migratePostgresCircuitBreaker);
141
+ await runInInjectionContext(injector, migratePostgresQueueSchema);
139
142
  }
140
143
  async function main() {
141
144
  const tenantId = '00000000-0000-0000-0000-000000000000';
package/orm/index.d.ts CHANGED
@@ -8,6 +8,6 @@ export * from './entity.js';
8
8
  export * from './query/index.js';
9
9
  export * from './repository.types.js';
10
10
  export * from './schemas/index.js';
11
- export * from './sqls.js';
11
+ export * from './sqls/index.js';
12
12
  export * from './types.js';
13
13
  export * from './utils.js';
package/orm/index.js CHANGED
@@ -8,6 +8,6 @@ export * from './entity.js';
8
8
  export * from './query/index.js';
9
9
  export * from './repository.types.js';
10
10
  export * from './schemas/index.js';
11
- export * from './sqls.js';
11
+ export * from './sqls/index.js';
12
12
  export * from './types.js';
13
13
  export * from './utils.js';
@@ -9,7 +9,7 @@ import type { AnyColumn, SQL, SQLWrapper } from 'drizzle-orm';
9
9
  import type { PartialDeep } from 'type-fest';
10
10
  import type { BaseEntity, Entity, EntityMetadata } from './entity.js';
11
11
  import type { FullTextSearchQuery, Query } from './query/index.js';
12
- import type { TsHeadlineOptions } from './sqls.js';
12
+ import type { TsHeadlineOptions } from './sqls/index.js';
13
13
  type WithSql<T> = {
14
14
  [P in keyof T]: T[P] extends Record ? WithSql<T[P]> : (T[P] | SQL);
15
15
  };
@@ -4,7 +4,7 @@ import { NotSupportedError } from '../../errors/not-supported.error.js';
4
4
  import { hasOwnProperty, mapObject, mapObjectKeysToSnakeCase, objectEntries } from '../../utils/object/object.js';
5
5
  import { toSnakeCase } from '../../utils/string/index.js';
6
6
  import { assert, assertDefinedPass, isArray, isDefined, isPrimitive, isRegExp, isString, isUndefined } from '../../utils/type-guards.js';
7
- import { array, isSimilar, isStrictWordSimilar, isWordSimilar, jsonbBuildObject, phraseToTsQuery, plainToTsQuery, setweight, similarity, strictWordSimilarity, toTsQuery, toTsVector, websearchToTsQuery, wordSimilarity } from '../sqls.js';
7
+ import { array, isSimilar, isStrictWordSimilar, isWordSimilar, jsonbBuildObject, phraseToTsQuery, plainToTsQuery, setweight, similarity, strictWordSimilarity, toTsQuery, toTsVector, websearchToTsQuery, wordSimilarity } from '../sqls/index.js';
8
8
  const sqlTrue = sql `true`;
9
9
  /**
10
10
  * Resolves a target to a Drizzle PgColumn or SQLWrapper.
@@ -23,7 +23,7 @@ import { assertDefined, assertDefinedPass, isArray, isBoolean, isDefined, isFunc
23
23
  import { typeExtends } from '../../utils/type/index.js';
24
24
  import { millisecondsPerSecond } from '../../utils/units.js';
25
25
  import { Entity } from '../entity.js';
26
- import { distance, isSimilar, isStrictWordSimilar, isWordSimilar, TRANSACTION_TIMESTAMP, tsHeadline, tsRankCd } from '../sqls.js';
26
+ import { distance, isSimilar, isStrictWordSimilar, isWordSimilar, TRANSACTION_TIMESTAMP, tsHeadline, tsRankCd } from '../sqls/index.js';
27
27
  import { getColumnDefinitions, getColumnDefinitionsMap, getDrizzleTableFromType } from './drizzle/schema-converter.js';
28
28
  import { convertQuery, getTsQuery, getTsVector, resolveTargetColumn } from './query-converter.js';
29
29
  import { ENCRYPTION_SECRET } from './tokens.js';
@@ -0,0 +1,25 @@
1
+ import { type SQL, type SQLWrapper } from 'drizzle-orm';
2
+ export declare class CaseBuilder<TReturn = never> {
3
+ readonly cases: SQL[];
4
+ caseExpression?: SQL | SQLWrapper;
5
+ constructor(expression?: SQL | SQLWrapper);
6
+ /** Adds a WHEN clause. */
7
+ when<TValue>(pattern: SQL | SQLWrapper | string | number | boolean | undefined, result: TValue | SQL | SQLWrapper): CaseBuilder<TReturn | TValue>;
8
+ /**
9
+ * Adds an ELSE clause and finishes the statement.
10
+ * If no WHEN clauses were added, it returns the value directly.
11
+ */
12
+ else<TElse>(value: TElse | SQL | SQLWrapper): SQL<TReturn | TElse>;
13
+ /**
14
+ * Finishes the statement without an ELSE (defaults to NULL).
15
+ * If no WHEN clauses were added, it returns NULL directly.
16
+ */
17
+ end(): SQL<TReturn | null>;
18
+ private finalize;
19
+ }
20
+ /**
21
+ * Creates a "Searched Case" builder.
22
+ * Syntax: CASE WHEN condition THEN result ...
23
+ */
24
+ export declare function caseWhen<TValue>(caseExpression: SQL | SQLWrapper): CaseBuilder<TValue>;
25
+ export declare function caseWhen<TValue>(condition: SQL | SQLWrapper | undefined, value: TValue | SQL | SQLWrapper): CaseBuilder<TValue>;
@@ -0,0 +1,54 @@
1
+ import { isDefined, isUndefined } from '../../utils/type-guards.js';
2
+ import { sql } from 'drizzle-orm';
3
+ export class CaseBuilder {
4
+ cases = [];
5
+ caseExpression;
6
+ constructor(expression) {
7
+ this.caseExpression = expression;
8
+ }
9
+ /** Adds a WHEN clause. */
10
+ when(pattern, result) {
11
+ if (isUndefined(pattern)) {
12
+ this.cases.push(sql `WHEN ${pattern} THEN ${result}`);
13
+ }
14
+ return this;
15
+ }
16
+ /**
17
+ * Adds an ELSE clause and finishes the statement.
18
+ * If no WHEN clauses were added, it returns the value directly.
19
+ */
20
+ else(value) {
21
+ if (this.cases.length == 0) {
22
+ return sql `${value}`;
23
+ }
24
+ return this.finalize(value);
25
+ }
26
+ /**
27
+ * Finishes the statement without an ELSE (defaults to NULL).
28
+ * If no WHEN clauses were added, it returns NULL directly.
29
+ */
30
+ end() {
31
+ if (this.cases.length == 0) {
32
+ return sql `NULL`;
33
+ }
34
+ return this.finalize();
35
+ }
36
+ finalize(elseValue) {
37
+ const chunks = [sql `CASE`];
38
+ if (isDefined(this.caseExpression)) {
39
+ chunks.push(sql `${this.caseExpression}`);
40
+ }
41
+ chunks.push(sql.join(this.cases, sql ` `));
42
+ const endChunk = isDefined(elseValue)
43
+ ? sql `ELSE ${elseValue} END`
44
+ : sql `END`;
45
+ chunks.push(endChunk);
46
+ return sql.join(chunks, sql ` `);
47
+ }
48
+ }
49
+ export function caseWhen(condition, value) {
50
+ if (isDefined(value)) {
51
+ return new CaseBuilder().when(condition, value);
52
+ }
53
+ return new CaseBuilder(condition);
54
+ }
@@ -0,0 +1,2 @@
1
+ export * from './sqls.js';
2
+ export * from './case-when.js';
@@ -0,0 +1,2 @@
1
+ export * from './sqls.js';
2
+ export * from './case-when.js';
@@ -6,18 +6,13 @@
6
6
  */
7
7
  import { Column, type AnyColumn, type SQL, type SQLChunk } from 'drizzle-orm';
8
8
  import type { GetSelectTableSelection, SelectResultField, TableLike } from 'drizzle-orm/query-builders/select.types';
9
- import type { EnumerationObject, EnumerationValue } from '../types/types.js';
10
- import { type PgEnumFromEnumeration } from './enums.js';
11
- import type { TsVectorWeight } from './query/index.js';
12
- import type { Uuid } from './types.js';
13
- /** Drizzle SQL helper for getting the current transaction's timestamp. Returns a Date object. */
14
- export declare const TRANSACTION_TIMESTAMP: SQL<Date>;
15
- /** Drizzle SQL helper for generating a random UUID (v4). Returns a Uuid string. */
16
- export declare const RANDOM_UUID_V4: SQL<Uuid>;
17
- /** Drizzle SQL helper for generating a random UUID (v7). Returns a Uuid string. */
18
- export declare const RANDOM_UUID_V7: SQL<Uuid>;
9
+ import type { EnumerationObject, EnumerationValue } from '../../types/types.js';
10
+ import { type PgEnumFromEnumeration } from '../enums.js';
11
+ import type { TsVectorWeight } from '../query/index.js';
12
+ import type { Uuid } from '../types.js';
19
13
  /** Represents valid units for PostgreSQL interval values. */
20
14
  export type IntervalUnit = 'millennium' | 'millenniums' | 'millennia' | 'century' | 'centuries' | 'decade' | 'decades' | 'year' | 'years' | 'day' | 'days' | 'hour' | 'hours' | 'minute' | 'minutes' | 'second' | 'seconds' | 'millisecond' | 'milliseconds' | 'microsecond' | 'microseconds';
15
+ export type ExclusiveColumnCondition = Column | boolean | SQL;
21
16
  export type TsHeadlineOptions = {
22
17
  /**
23
18
  * The longest headline to output.
@@ -60,14 +55,41 @@ export type TsHeadlineOptions = {
60
55
  */
61
56
  fragmentDelimiter?: string;
62
57
  };
58
+ /** Drizzle SQL helper for getting the current transaction's timestamp. Returns a Date object. */
59
+ export declare const TRANSACTION_TIMESTAMP: SQL<Date>;
60
+ /** Drizzle SQL helper for generating a random UUID (v4). Returns a Uuid string. */
61
+ export declare const RANDOM_UUID_V4: SQL<Uuid>;
62
+ /** Drizzle SQL helper for generating a random UUID (v7). Returns a Uuid string. */
63
+ export declare const RANDOM_UUID_V7: SQL<Uuid>;
64
+ export declare const SQL_TRUE: SQL<boolean>;
65
+ export declare const SQL_FALSE: SQL<boolean>;
63
66
  export declare function enumValue<T extends EnumerationObject>(enumeration: T, dbEnum: PgEnumFromEnumeration<T> | string | null, value: EnumerationValue<T>): SQL<string>;
64
67
  /**
65
- * Generates a SQL condition that ensures exactly one of the specified columns is non-null and optional custom conditions.
66
- * @param enumeration The enumeration object
67
- * @param discriminator The column used to discriminate between different enumeration values
68
- * @param conditionMapping An object mapping enumeration values to columns and conditions
68
+ * Generates a SQL `CASE` expression to enforce strict, mutually exclusive column usage based on a discriminator value.
69
+ *
70
+ * This function is useful for "Table per Hierarchy" inheritance patterns or polymorphic associations where specific columns should only be present when a discriminator holds a specific value.
71
+ * Particularly in conjunction with check constraints, it ensures data integrity by enforcing that only the relevant columns for a given discriminator value are populated.
72
+ *
73
+ * The logic ensures that for a specific enum value:
74
+ * 1. Columns explicitly mapped to that value are enforced as **IS NOT NULL**.
75
+ * 2. Columns mapped to *other* enum values (but not the current one) are enforced as **IS NULL**.
76
+ * 3. Any custom SQL conditions provided are combined via `AND`.
77
+ *
78
+ * @remarks
79
+ * The function collects all columns defined across the entire `conditionMapping`. If a column appears anywhere in the mapping but is not associated with the current discriminator value being evaluated, it is automatically asserted as `NULL`.
80
+ *
81
+ * @param enumeration - The source enumeration object containing the valid discriminator values.
82
+ * @param discriminator - The database column acting as the type discriminator.
83
+ * @param conditionMapping - A configuration object where keys are enum values and values are:
84
+ * - A `Column` (enforced as NOT NULL).
85
+ * - A `SQL` condition (passed through).
86
+ * - A boolean (converted to SQL TRUE/FALSE).
87
+ * - An array containing a mix of the above.
88
+ * - `null` (implies this enum value is invalid or should result in `FALSE`).
89
+ *
90
+ * @returns A SQL object representing the complete `CASE discriminator WHEN ... THEN ... ELSE FALSE` statement.
69
91
  */
70
- export declare function exclusiveReference<T extends EnumerationObject>(enumeration: T, discriminator: Column, conditionMapping: Record<EnumerationValue<T>, Column | [Column, condition: boolean | SQL] | null>): SQL;
92
+ export declare function exclusiveColumn<T extends EnumerationObject>(enumeration: T, discriminator: Column, conditionMapping: Record<EnumerationValue<T>, ExclusiveColumnCondition | [ExclusiveColumnCondition, ...ExclusiveColumnCondition[]] | null>): SQL;
71
93
  export declare function exclusiveNotNull(...columns: Column[]): SQL;
72
94
  /**
73
95
  * Generates a SQL `CASE ... WHEN ... END` statement for dynamic condition mapping based on an enumeration.
@@ -91,7 +113,7 @@ export declare function exclusiveNotNull(...columns: Column[]): SQL;
91
113
  * that defines the default condition to apply when a `Column` is provided in `conditionMapping`.
92
114
  * By default, it generates an `IS NOT NULL` check.
93
115
  */
94
- export declare function enumerationCaseWhen<T extends EnumerationObject>(enumeration: T, discriminator: Column, conditionMapping: Record<EnumerationValue<T>, Column | boolean | SQL>, defaultColumnCondition?: (column: Column) => SQL<unknown>): SQL;
116
+ export declare function enumerationCaseWhen<T extends EnumerationObject>(enumeration: T, discriminator: Column, conditionMapping: Record<EnumerationValue<T>, Column | [Column, ...Column[]] | boolean | SQL>, defaultColumnCondition?: (column: [Column, ...Column[]]) => SQL<unknown> | undefined): SQL;
95
117
  export declare function array<T>(values: SQL<T>[]): SQL<T[]>;
96
118
  export declare function array<T = unknown>(values: SQLChunk[]): SQL<T[]>;
97
119
  export declare function autoAlias<T>(column: AnyColumn<{
@@ -4,17 +4,21 @@
4
4
  * simplifying common SQL operations like generating UUIDs, working with intervals,
5
5
  * and aggregating data.
6
6
  */
7
- import { and, Column, eq, sql, isNotNull as sqlIsNotNull, Table } from 'drizzle-orm';
7
+ import { and, Column, eq, sql, isNotNull as sqlIsNotNull, isNull as sqlIsNull, Table } from 'drizzle-orm';
8
8
  import { match, P } from 'ts-pattern';
9
- import { mapObjectValues, objectEntries, objectValues } from '../utils/object/object.js';
10
- import { assertDefined, isArray, isDefined, isInstanceOf, isNotNull, isNull, isNumber, isString } from '../utils/type-guards.js';
11
- import { getEnumName } from './enums.js';
9
+ import { distinct, toArray } from '../../utils/array/array.js';
10
+ import { objectEntries, objectValues } from '../../utils/object/object.js';
11
+ import { assertDefined, isArray, isBoolean, isDefined, isInstanceOf, isNotNull, isNull, isNumber, isString } from '../../utils/type-guards.js';
12
+ import { getEnumName } from '../enums.js';
13
+ import { caseWhen } from './case-when.js';
12
14
  /** Drizzle SQL helper for getting the current transaction's timestamp. Returns a Date object. */
13
15
  export const TRANSACTION_TIMESTAMP = sql `transaction_timestamp()`;
14
16
  /** Drizzle SQL helper for generating a random UUID (v4). Returns a Uuid string. */
15
17
  export const RANDOM_UUID_V4 = sql `gen_random_uuid()`;
16
18
  /** Drizzle SQL helper for generating a random UUID (v7). Returns a Uuid string. */
17
19
  export const RANDOM_UUID_V7 = sql `uuidv7()`;
20
+ export const SQL_TRUE = sql `TRUE`;
21
+ export const SQL_FALSE = sql `FALSE`;
18
22
  export function enumValue(enumeration, dbEnum, value) {
19
23
  if (isNull(dbEnum)) {
20
24
  const enumName = getEnumName(enumeration);
@@ -25,23 +29,50 @@ export function enumValue(enumeration, dbEnum, value) {
25
29
  return sql `'${sql.raw(String(value))}'::${enumType}`;
26
30
  }
27
31
  /**
28
- * Generates a SQL condition that ensures exactly one of the specified columns is non-null and optional custom conditions.
29
- * @param enumeration The enumeration object
30
- * @param discriminator The column used to discriminate between different enumeration values
31
- * @param conditionMapping An object mapping enumeration values to columns and conditions
32
+ * Generates a SQL `CASE` expression to enforce strict, mutually exclusive column usage based on a discriminator value.
33
+ *
34
+ * This function is useful for "Table per Hierarchy" inheritance patterns or polymorphic associations where specific columns should only be present when a discriminator holds a specific value.
35
+ * Particularly in conjunction with check constraints, it ensures data integrity by enforcing that only the relevant columns for a given discriminator value are populated.
36
+ *
37
+ * The logic ensures that for a specific enum value:
38
+ * 1. Columns explicitly mapped to that value are enforced as **IS NOT NULL**.
39
+ * 2. Columns mapped to *other* enum values (but not the current one) are enforced as **IS NULL**.
40
+ * 3. Any custom SQL conditions provided are combined via `AND`.
41
+ *
42
+ * @remarks
43
+ * The function collects all columns defined across the entire `conditionMapping`. If a column appears anywhere in the mapping but is not associated with the current discriminator value being evaluated, it is automatically asserted as `NULL`.
44
+ *
45
+ * @param enumeration - The source enumeration object containing the valid discriminator values.
46
+ * @param discriminator - The database column acting as the type discriminator.
47
+ * @param conditionMapping - A configuration object where keys are enum values and values are:
48
+ * - A `Column` (enforced as NOT NULL).
49
+ * - A `SQL` condition (passed through).
50
+ * - A boolean (converted to SQL TRUE/FALSE).
51
+ * - An array containing a mix of the above.
52
+ * - `null` (implies this enum value is invalid or should result in `FALSE`).
53
+ *
54
+ * @returns A SQL object representing the complete `CASE discriminator WHEN ... THEN ... ELSE FALSE` statement.
32
55
  */
33
- export function exclusiveReference(enumeration, discriminator, conditionMapping) {
34
- const columns = objectValues(conditionMapping).filter(isNotNull).map((value) => isInstanceOf(value, Column) ? value : value[0]);
35
- const mapping = mapObjectValues(conditionMapping, (value) => {
36
- if (isInstanceOf(value, Column)) {
37
- return value;
38
- }
56
+ export function exclusiveColumn(enumeration, discriminator, conditionMapping) {
57
+ const allColumns = objectValues(conditionMapping)
58
+ .filter(isNotNull)
59
+ .flatMap((value) => toArray(value).filter((value) => isInstanceOf(value, Column)));
60
+ const participatingColumns = distinct(allColumns);
61
+ const mapping = objectEntries(conditionMapping).map(([key, value]) => {
39
62
  if (isNull(value)) {
40
- return sql `FALSE`;
63
+ return [key, SQL_FALSE];
41
64
  }
42
- return value[1];
65
+ const requiredColumns = toArray(value).filter((value) => isInstanceOf(value, Column));
66
+ const nullColumns = participatingColumns.filter((column) => !requiredColumns.includes(column));
67
+ const customConditions = toArray(value).filter((val) => !isInstanceOf(val, Column)).map((condition) => isBoolean(condition) ? (condition ? SQL_TRUE : SQL_FALSE) : condition);
68
+ const condition = and(...requiredColumns.map((col) => sqlIsNotNull(col)), ...nullColumns.map((col) => sqlIsNull(col)), ...customConditions);
69
+ return [key, condition];
43
70
  });
44
- return and(exclusiveNotNull(...columns), enumerationCaseWhen(enumeration, discriminator, mapping));
71
+ const kaseWhen = caseWhen(discriminator);
72
+ for (const [key, condition] of mapping) {
73
+ kaseWhen.when(enumValue(enumeration, null, key), condition);
74
+ }
75
+ return kaseWhen.else(SQL_FALSE);
45
76
  }
46
77
  export function exclusiveNotNull(...columns) {
47
78
  return eq(numNonNulls(...columns), sql.raw('1'));
@@ -68,11 +99,11 @@ export function exclusiveNotNull(...columns) {
68
99
  * that defines the default condition to apply when a `Column` is provided in `conditionMapping`.
69
100
  * By default, it generates an `IS NOT NULL` check.
70
101
  */
71
- export function enumerationCaseWhen(enumeration, discriminator, conditionMapping, defaultColumnCondition = (column) => sqlIsNotNull(column)) {
102
+ export function enumerationCaseWhen(enumeration, discriminator, conditionMapping, defaultColumnCondition = (column) => isArray(column) ? and(...column.map((col) => sqlIsNotNull(col))) : sqlIsNotNull(column)) {
72
103
  const whens = [];
73
104
  for (const [key, value] of objectEntries(conditionMapping)) {
74
105
  const condition = match(value)
75
- .with(P.boolean, (bool) => bool ? sql `TRUE` : sql `FALSE`)
106
+ .with(P.boolean, (bool) => bool ? SQL_TRUE : SQL_FALSE)
76
107
  .when((value) => isInstanceOf(value, Column), (col) => defaultColumnCondition(col))
77
108
  .otherwise((rawSql) => rawSql);
78
109
  whens.push(sql ` WHEN ${enumValue(enumeration, null, key)} THEN ${condition}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.85",
3
+ "version": "0.93.87",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -145,7 +145,7 @@
145
145
  "peerDependencies": {
146
146
  "@genkit-ai/google-genai": "^1.27",
147
147
  "@google-cloud/storage": "^7.18",
148
- "@google/genai": "^1.34",
148
+ "@google/genai": "^1.35",
149
149
  "@toon-format/toon": "^2.1.0",
150
150
  "@tstdl/angular": "^0.93",
151
151
  "@zxcvbn-ts/core": "^3.0",
@@ -174,7 +174,7 @@
174
174
  }
175
175
  },
176
176
  "devDependencies": {
177
- "@stylistic/eslint-plugin": "5.6",
177
+ "@stylistic/eslint-plugin": "5.7",
178
178
  "@types/koa__router": "12.0",
179
179
  "@types/luxon": "3.7",
180
180
  "@types/mjml": "4.7",