@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.
- package/authentication/client/authentication.service.d.ts +2 -2
- package/authentication/client/authentication.service.js +2 -2
- package/authentication/models/authentication-credentials.model.d.ts +3 -6
- package/authentication/models/authentication-credentials.model.js +3 -6
- package/authentication/models/authentication-session.model.d.ts +3 -6
- package/authentication/models/authentication-session.model.js +3 -6
- package/authentication/models/token.model.d.ts +12 -30
- package/authentication/server/authentication.audit.d.ts +1 -0
- package/authentication/server/authentication.service.js +49 -16
- package/examples/document-management/main.js +5 -2
- package/orm/index.d.ts +1 -1
- package/orm/index.js +1 -1
- package/orm/repository.types.d.ts +1 -1
- package/orm/server/query-converter.js +1 -1
- package/orm/server/repository.js +1 -1
- package/orm/sqls/case-when.d.ts +25 -0
- package/orm/sqls/case-when.js +54 -0
- package/orm/sqls/index.d.ts +2 -0
- package/orm/sqls/index.js +2 -0
- package/orm/{sqls.d.ts → sqls/sqls.d.ts} +38 -16
- package/orm/{sqls.js → sqls/sqls.js} +50 -19
- package/package.json +3 -3
|
@@ -84,10 +84,10 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
|
|
|
84
84
|
*/
|
|
85
85
|
get definedTenantId(): string;
|
|
86
86
|
/**
|
|
87
|
-
* Get current
|
|
87
|
+
* Get current subjectId or throw if not available
|
|
88
88
|
* @throws Will throw if subject is not available
|
|
89
89
|
*/
|
|
90
|
-
get
|
|
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
|
|
143
|
+
* Get current subjectId or throw if not available
|
|
144
144
|
* @throws Will throw if subject is not available
|
|
145
145
|
*/
|
|
146
|
-
get
|
|
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
|
-
|
|
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
|
}>;
|
|
@@ -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 {
|
|
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(
|
|
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
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
611
|
-
const salt = getRandomBytes(
|
|
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(
|
|
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:
|
|
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:
|
|
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,
|
|
138
|
-
await runInInjectionContext(injector,
|
|
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.
|
package/orm/server/repository.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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 '
|
|
10
|
-
import { type PgEnumFromEnumeration } from '
|
|
11
|
-
import type { TsVectorWeight } from '
|
|
12
|
-
import type { Uuid } from '
|
|
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
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
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 {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
63
|
+
return [key, SQL_FALSE];
|
|
41
64
|
}
|
|
42
|
-
|
|
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
|
-
|
|
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 ?
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|