@rvoh/dream 2.10.0 → 2.11.2
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/dist/cjs/src/cli/index.js +16 -3
- package/dist/cjs/src/db/DreamDbConnection.js +45 -1
- package/dist/cjs/src/db/dbConnectionLeakDiagnostics.js +117 -0
- package/dist/cjs/src/dream/QueryDriver/Kysely.js +17 -4
- package/dist/cjs/src/encrypt/index.js +5 -5
- package/dist/cjs/src/errors/encrypt/{DecryptionWithRotationError.js → DecryptionRotationError.js} +1 -1
- package/dist/cjs/src/helpers/cli/generateDreamContent.js +43 -17
- package/dist/cjs/src/helpers/cli/generateFactoryContent.js +34 -7
- package/dist/cjs/src/helpers/cli/generateMigrationContent.js +30 -4
- package/dist/cjs/src/helpers/cli/parseAttribute.js +61 -0
- package/dist/cjs/src/package-exports/errors.js +3 -0
- package/dist/esm/src/cli/index.js +16 -3
- package/dist/esm/src/db/DreamDbConnection.js +45 -1
- package/dist/esm/src/db/dbConnectionLeakDiagnostics.js +117 -0
- package/dist/esm/src/dream/QueryDriver/Kysely.js +17 -4
- package/dist/esm/src/encrypt/index.js +5 -5
- package/dist/esm/src/errors/encrypt/{DecryptionWithRotationError.js → DecryptionRotationError.js} +1 -1
- package/dist/esm/src/helpers/cli/generateDreamContent.js +43 -17
- package/dist/esm/src/helpers/cli/generateFactoryContent.js +34 -7
- package/dist/esm/src/helpers/cli/generateMigrationContent.js +30 -4
- package/dist/esm/src/helpers/cli/parseAttribute.js +61 -0
- package/dist/esm/src/package-exports/errors.js +3 -0
- package/dist/types/src/cli/index.d.ts +1 -1
- package/dist/types/src/db/dbConnectionLeakDiagnostics.d.ts +10 -0
- package/dist/types/src/dream-app/index.d.ts +21 -0
- package/dist/types/src/encrypt/index.d.ts +3 -3
- package/dist/types/src/errors/encrypt/{DecryptionWithRotationError.d.ts → DecryptionRotationError.d.ts} +1 -1
- package/dist/types/src/helpers/cli/generateDreamContent.d.ts +6 -2
- package/dist/types/src/helpers/cli/parseAttribute.d.ts +64 -0
- package/dist/types/src/package-exports/errors.d.ts +3 -0
- package/docs/assets/navigation.js +1 -1
- package/docs/assets/search.js +1 -1
- package/docs/classes/db.DreamMigrationHelpers.html +9 -9
- package/docs/classes/db.KyselyQueryDriver.html +32 -32
- package/docs/classes/db.PostgresQueryDriver.html +33 -33
- package/docs/classes/db.QueryDriverBase.html +31 -31
- package/docs/classes/errors.CheckConstraintViolation.html +3 -3
- package/docs/classes/errors.ColumnOverflow.html +3 -3
- package/docs/classes/errors.CreateOrFindByFailedToCreateAndFind.html +3 -3
- package/docs/classes/errors.DataIncompatibleWithDatabaseField.html +3 -3
- package/docs/classes/errors.DataTypeColumnTypeMismatch.html +3 -3
- package/docs/classes/errors.DecryptionError.html +33 -0
- package/docs/classes/errors.DecryptionParseError.html +33 -0
- package/docs/classes/errors.DecryptionRotationError.html +35 -0
- package/docs/classes/errors.GlobalNameNotSet.html +3 -3
- package/docs/classes/errors.InvalidCalendarDate.html +2 -2
- package/docs/classes/errors.InvalidClockTime.html +2 -2
- package/docs/classes/errors.InvalidClockTimeTz.html +2 -2
- package/docs/classes/errors.InvalidDateTime.html +2 -2
- package/docs/classes/errors.MissingSerializersDefinition.html +3 -3
- package/docs/classes/errors.NonLoadedAssociation.html +3 -3
- package/docs/classes/errors.NotNullViolation.html +3 -3
- package/docs/classes/errors.RecordNotFound.html +3 -3
- package/docs/classes/errors.ValidationError.html +3 -3
- package/docs/classes/index.CalendarDate.html +33 -33
- package/docs/classes/index.ClockTime.html +32 -32
- package/docs/classes/index.ClockTimeTz.html +35 -35
- package/docs/classes/index.DateTime.html +86 -86
- package/docs/classes/index.Decorators.html +19 -19
- package/docs/classes/index.Dream.html +118 -118
- package/docs/classes/index.DreamApp.html +5 -5
- package/docs/classes/index.DreamTransaction.html +2 -2
- package/docs/classes/index.Env.html +2 -2
- package/docs/classes/index.Query.html +56 -56
- package/docs/classes/system.CliFileWriter.html +4 -4
- package/docs/classes/system.DreamBin.html +2 -2
- package/docs/classes/system.DreamCLI.html +7 -7
- package/docs/classes/system.DreamImporter.html +2 -2
- package/docs/classes/system.DreamLogos.html +2 -2
- package/docs/classes/system.DreamSerializerBuilder.html +11 -11
- package/docs/classes/system.ObjectSerializerBuilder.html +8 -8
- package/docs/classes/system.PathHelpers.html +3 -3
- package/docs/classes/utils.Encrypt.html +6 -6
- package/docs/classes/utils.Range.html +2 -2
- package/docs/functions/db.closeAllDbConnections.html +1 -1
- package/docs/functions/db.dreamDbConnections.html +1 -1
- package/docs/functions/db.untypedDb.html +1 -1
- package/docs/functions/db.validateColumn.html +1 -1
- package/docs/functions/db.validateTable.html +1 -1
- package/docs/functions/errors.pgErrorType.html +1 -1
- package/docs/functions/index.DreamSerializer.html +1 -1
- package/docs/functions/index.ObjectSerializer.html +1 -1
- package/docs/functions/index.ReplicaSafe.html +1 -1
- package/docs/functions/index.STI.html +1 -1
- package/docs/functions/index.SoftDelete.html +1 -1
- package/docs/functions/utils.camelize.html +1 -1
- package/docs/functions/utils.capitalize.html +1 -1
- package/docs/functions/utils.cloneDeepSafe.html +1 -1
- package/docs/functions/utils.compact.html +1 -1
- package/docs/functions/utils.groupBy.html +1 -1
- package/docs/functions/utils.hyphenize.html +1 -1
- package/docs/functions/utils.intersection.html +1 -1
- package/docs/functions/utils.isEmpty.html +1 -1
- package/docs/functions/utils.normalizeUnicode.html +1 -1
- package/docs/functions/utils.pascalize.html +1 -1
- package/docs/functions/utils.percent.html +1 -1
- package/docs/functions/utils.range.html +1 -1
- package/docs/functions/utils.round.html +1 -1
- package/docs/functions/utils.sanitizeString.html +1 -1
- package/docs/functions/utils.snakeify.html +1 -1
- package/docs/functions/utils.sort.html +1 -1
- package/docs/functions/utils.sortBy.html +1 -1
- package/docs/functions/utils.sortObjectByKey.html +1 -1
- package/docs/functions/utils.sortObjectByValue.html +1 -1
- package/docs/functions/utils.uncapitalize.html +1 -1
- package/docs/functions/utils.uniq.html +1 -1
- package/docs/interfaces/openapi.OpenapiDescription.html +2 -2
- package/docs/interfaces/openapi.OpenapiSchemaProperties.html +1 -1
- package/docs/interfaces/openapi.OpenapiSchemaPropertiesShorthand.html +1 -1
- package/docs/interfaces/openapi.OpenapiTypeFieldObject.html +1 -1
- package/docs/interfaces/types.BelongsToStatement.html +2 -2
- package/docs/interfaces/types.DecoratorContext.html +2 -2
- package/docs/interfaces/types.DreamAppInitOptions.html +2 -2
- package/docs/interfaces/types.DreamAppOpts.html +2 -2
- package/docs/interfaces/types.DurationObject.html +2 -2
- package/docs/interfaces/types.EncryptOptions.html +2 -2
- package/docs/interfaces/types.InternalAnyTypedSerializerRendersMany.html +2 -2
- package/docs/interfaces/types.InternalAnyTypedSerializerRendersOne.html +2 -2
- package/docs/interfaces/types.SerializerRendererOpts.html +2 -2
- package/docs/modules/errors.html +1 -1
- package/docs/types/openapi.CommonOpenapiSchemaObjectFields.html +1 -1
- package/docs/types/openapi.OpenapiAllTypes.html +1 -1
- package/docs/types/openapi.OpenapiFormats.html +1 -1
- package/docs/types/openapi.OpenapiNumberFormats.html +1 -1
- package/docs/types/openapi.OpenapiPrimitiveBaseTypes.html +1 -1
- package/docs/types/openapi.OpenapiPrimitiveTypes.html +1 -1
- package/docs/types/openapi.OpenapiSchemaArray.html +1 -1
- package/docs/types/openapi.OpenapiSchemaArrayShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaBase.html +1 -1
- package/docs/types/openapi.OpenapiSchemaBody.html +1 -1
- package/docs/types/openapi.OpenapiSchemaBodyShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaCommonFields.html +1 -1
- package/docs/types/openapi.OpenapiSchemaExpressionAllOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaExpressionAnyOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaExpressionOneOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaExpressionRef.html +2 -2
- package/docs/types/openapi.OpenapiSchemaExpressionRefSchemaShorthand.html +2 -2
- package/docs/types/openapi.OpenapiSchemaInteger.html +1 -1
- package/docs/types/openapi.OpenapiSchemaNull.html +2 -2
- package/docs/types/openapi.OpenapiSchemaNumber.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObject.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectAllOf.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectAllOfShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectAnyOf.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectAnyOfShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectBase.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectBaseShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectOneOf.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectOneOfShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaObjectShorthand.html +1 -1
- package/docs/types/openapi.OpenapiSchemaPrimitiveGeneric.html +1 -1
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionAllOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionAnyOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionOneOf.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializableRef.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandExpressionSerializerRef.html +2 -2
- package/docs/types/openapi.OpenapiSchemaShorthandPrimitiveGeneric.html +1 -1
- package/docs/types/openapi.OpenapiSchemaString.html +1 -1
- package/docs/types/openapi.OpenapiShorthandAllTypes.html +1 -1
- package/docs/types/openapi.OpenapiShorthandPrimitiveBaseTypes.html +1 -1
- package/docs/types/openapi.OpenapiShorthandPrimitiveTypes.html +1 -1
- package/docs/types/openapi.OpenapiTypeField.html +1 -1
- package/docs/types/system.DreamAppAllowedPackageManagersEnum.html +1 -1
- package/docs/types/types.CalendarDateDurationUnit.html +1 -1
- package/docs/types/types.CalendarDateObject.html +1 -1
- package/docs/types/types.Camelized.html +1 -1
- package/docs/types/types.ClockTimeObject.html +1 -1
- package/docs/types/types.DbConnectionType.html +1 -1
- package/docs/types/types.DbTypes.html +1 -1
- package/docs/types/types.DreamAssociationMetadata.html +1 -1
- package/docs/types/types.DreamAttributes.html +1 -1
- package/docs/types/types.DreamClassAssociationAndStatement.html +1 -1
- package/docs/types/types.DreamClassColumn.html +1 -1
- package/docs/types/types.DreamColumn.html +1 -1
- package/docs/types/types.DreamColumnNames.html +1 -1
- package/docs/types/types.DreamLogLevel.html +1 -1
- package/docs/types/types.DreamLogger.html +2 -2
- package/docs/types/types.DreamModelSerializerType.html +1 -1
- package/docs/types/types.DreamOrViewModelClassSerializerKey.html +1 -1
- package/docs/types/types.DreamOrViewModelSerializerKey.html +1 -1
- package/docs/types/types.DreamParamSafeAttributes.html +1 -1
- package/docs/types/types.DreamParamSafeColumnNames.html +1 -1
- package/docs/types/types.DreamSerializable.html +1 -1
- package/docs/types/types.DreamSerializableArray.html +1 -1
- package/docs/types/types.DreamSerializerKey.html +1 -1
- package/docs/types/types.DreamSerializers.html +1 -1
- package/docs/types/types.DreamVirtualColumns.html +1 -1
- package/docs/types/types.DurationUnit.html +1 -1
- package/docs/types/types.EncryptAlgorithm.html +1 -1
- package/docs/types/types.HasManyStatement.html +1 -1
- package/docs/types/types.HasOneStatement.html +1 -1
- package/docs/types/types.Hyphenized.html +1 -1
- package/docs/types/types.Pascalized.html +1 -1
- package/docs/types/types.PrimaryKeyType.html +1 -1
- package/docs/types/types.RoundingPrecision.html +1 -1
- package/docs/types/types.SerializerCasing.html +1 -1
- package/docs/types/types.SimpleObjectSerializerType.html +1 -1
- package/docs/types/types.Snakeified.html +1 -1
- package/docs/types/types.StrictInterface.html +1 -1
- package/docs/types/types.UpdateableAssociationProperties.html +1 -1
- package/docs/types/types.UpdateableProperties.html +1 -1
- package/docs/types/types.ValidationType.html +1 -1
- package/docs/types/types.ViewModel.html +2 -2
- package/docs/types/types.ViewModelClass.html +1 -1
- package/docs/types/types.WeekdayName.html +1 -1
- package/docs/types/types.WhereStatementForDream.html +1 -1
- package/docs/types/types.WhereStatementForDreamClass.html +1 -1
- package/docs/variables/index.DreamConst.html +1 -1
- package/docs/variables/index.ops.html +1 -1
- package/docs/variables/openapi.openapiPrimitiveTypes.html +1 -1
- package/docs/variables/openapi.openapiShorthandPrimitiveTypes.html +1 -1
- package/docs/variables/system.DreamAppAllowedPackageManagersEnumValues.html +1 -1
- package/docs/variables/system.primaryKeyTypes.html +1 -1
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ import DreamCliLogger from './logger/DreamCliLogger.js';
|
|
|
11
11
|
import colorize from './logger/loggable/colorize.js';
|
|
12
12
|
export const CLI_INDENT = ' ';
|
|
13
13
|
const INDENT = CLI_INDENT;
|
|
14
|
-
export const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name) properties like this:
|
|
14
|
+
export const baseColumnsWithTypesDescription = `space separated snake-case (except for belongs_to model name, which may take an @alias suffix to rename the FK) properties like this:
|
|
15
15
|
${INDENT} title:citext subtitle:string body_markdown:text style:enum:post_styles:formal,informal User:belongs_to
|
|
16
16
|
${INDENT}
|
|
17
17
|
${INDENT}all properties default to not nullable; null can be allowed by appending ':optional':
|
|
@@ -71,7 +71,16 @@ ${INDENT}
|
|
|
71
71
|
${INDENT} use the fully qualified model name (matching its path under src/app/models/):
|
|
72
72
|
${INDENT} User:belongs_to # creates user_id column + BelongsTo association
|
|
73
73
|
${INDENT} Health/Coach:belongs_to # creates health_coach_id column + BelongsTo association
|
|
74
|
-
${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)
|
|
74
|
+
${INDENT} User:belongs_to:optional # nullable foreign key (for optional associations)
|
|
75
|
+
${INDENT}
|
|
76
|
+
${INDENT} rename the association with Model@alias — the snake_case alias drives the FK column name AND the
|
|
77
|
+
${INDENT} @deco.BelongsTo association + typed FK property on the generated model:
|
|
78
|
+
${INDENT} InternalUser@canceled_by:belongs_to:optional # canceled_by_id column, canceledById property, canceledBy association,
|
|
79
|
+
${INDENT} # @deco.BelongsTo('InternalUser', { on: 'canceledById', optional: true })
|
|
80
|
+
${INDENT} Messaging/Template@template:belongs_to # template_id column, templateId property, template association
|
|
81
|
+
${INDENT} # (strips the namespace from the property/association names while keeping
|
|
82
|
+
${INDENT} # the namespaced model reference intact)
|
|
83
|
+
${INDENT} Aliasing also lets you declare multiple FKs to the same model in one generator call without column collisions.`;
|
|
75
84
|
const columnsWithTypesDescriptionForMigration = baseColumnsWithTypesDescription +
|
|
76
85
|
`
|
|
77
86
|
${INDENT}
|
|
@@ -82,7 +91,11 @@ ${INDENT}
|
|
|
82
91
|
${INDENT} use the fully qualified model name (matching its path under src/app/models/):
|
|
83
92
|
${INDENT} User:belongs_to # creates user_id column with index
|
|
84
93
|
${INDENT} Health/Coach:belongs_to # creates health_coach_id column with index
|
|
85
|
-
${INDENT} User:belongs_to:optional # nullable foreign key
|
|
94
|
+
${INDENT} User:belongs_to:optional # nullable foreign key
|
|
95
|
+
${INDENT}
|
|
96
|
+
${INDENT} rename the FK column with Model@alias (snake_case alias becomes the column name):
|
|
97
|
+
${INDENT} InternalUser@canceled_by:belongs_to:optional # canceled_by_id column with index
|
|
98
|
+
${INDENT} Messaging/Template@template:belongs_to # template_id column (strips the namespace from the column name)`;
|
|
86
99
|
export default class DreamCLI {
|
|
87
100
|
/**
|
|
88
101
|
* Starts the Dream console
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { CamelCasePlugin, Kysely } from 'kysely';
|
|
10
10
|
import DreamApp from '../dream-app/index.js';
|
|
11
11
|
import protectAgainstPollutingAssignment from '../helpers/protectAgainstPollutingAssignment.js';
|
|
12
|
+
import { installDbConnectionLeakDiagnosticsIfEnabled, reportLeakedDbConnections, } from './dbConnectionLeakDiagnostics.js';
|
|
12
13
|
let connections = {};
|
|
13
14
|
export default class DreamDbConnection {
|
|
14
15
|
static getConnection(connectionName, connectionType, dialectProvider) {
|
|
@@ -19,6 +20,9 @@ export default class DreamDbConnection {
|
|
|
19
20
|
return connection;
|
|
20
21
|
}
|
|
21
22
|
const connectionConf = dreamApp.dbConnectionConfig(connectionName, connectionType);
|
|
23
|
+
// Must run before dialectProvider() constructs the pg.Pool. Idempotent and
|
|
24
|
+
// a no-op unless DREAM_DB_LEAK_DIAGNOSTICS is set.
|
|
25
|
+
installDbConnectionLeakDiagnosticsIfEnabled();
|
|
22
26
|
const dbConn = new Kysely({
|
|
23
27
|
log(event) {
|
|
24
28
|
const dreamApp = DreamApp.getOrFail();
|
|
@@ -50,11 +54,51 @@ export async function closeAllDbConnections() {
|
|
|
50
54
|
}
|
|
51
55
|
connections = {};
|
|
52
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Upper bound (ms) on how long {@link closeAllConnectionsForConnectionName}
|
|
59
|
+
* waits for a single Kysely connection's underlying pool to drain.
|
|
60
|
+
*
|
|
61
|
+
* `conn.destroy()` resolves to `pg`'s `pool.end()`, which only settles once
|
|
62
|
+
* every checked-out client has been released back to the pool. A client that
|
|
63
|
+
* was leased by a query still in flight when shutdown began — an aborted HTTP
|
|
64
|
+
* request during a SIGTERM drain, a feature-spec whose page is torn down
|
|
65
|
+
* mid-request — is never released, so `pool.end()` blocks forever and takes
|
|
66
|
+
* the whole shutdown with it. Bounding the wait keeps shutdown deterministic;
|
|
67
|
+
* the leaked socket is reaped by the OS when the process exits.
|
|
68
|
+
*/
|
|
69
|
+
const DB_CONNECTION_CLOSE_TIMEOUT_MS = 10_000;
|
|
53
70
|
export async function closeAllConnectionsForConnectionName(connectionName) {
|
|
54
71
|
const protectedName = protectAgainstPollutingAssignment(connectionName);
|
|
55
72
|
return await Promise.allSettled(Object.keys(connections[protectedName]).map(async (key) => {
|
|
56
73
|
const conn = connections[protectedName][key];
|
|
57
|
-
|
|
74
|
+
// Remove from the registry first so a subsequent getConnection() builds a
|
|
75
|
+
// fresh pool even if this drain times out and the old pool is abandoned.
|
|
58
76
|
delete connections[protectedName][key];
|
|
77
|
+
await destroyConnectionWithinTimeout(conn, `${connectionName}:${key}`);
|
|
59
78
|
}));
|
|
60
79
|
}
|
|
80
|
+
async function destroyConnectionWithinTimeout(conn, label) {
|
|
81
|
+
let timer;
|
|
82
|
+
const timedOut = Symbol('timedOut');
|
|
83
|
+
const timeout = new Promise(resolve => {
|
|
84
|
+
timer = setTimeout(() => resolve(timedOut), DB_CONNECTION_CLOSE_TIMEOUT_MS);
|
|
85
|
+
// Don't let the timer itself keep the event loop (or `process.exit`) alive.
|
|
86
|
+
timer.unref?.();
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
const result = await Promise.race([conn.destroy(), timeout]);
|
|
90
|
+
if (result === timedOut) {
|
|
91
|
+
DreamApp.logWithLevel('warn', `[dream] timed out after ${DB_CONNECTION_CLOSE_TIMEOUT_MS}ms waiting for db connection "${label}" ` +
|
|
92
|
+
`to close; abandoning the drain so shutdown can proceed. A pooled client was most likely held ` +
|
|
93
|
+
`past shutdown by an in-flight or aborted query. ` +
|
|
94
|
+
`Re-run with NODE_DEBUG=dream to log the acquire stack of the offending query.`);
|
|
95
|
+
// No-op unless NODE_DEBUG=dream; when set, this prints the acquire
|
|
96
|
+
// stack(s) of the client(s) that never got released.
|
|
97
|
+
reportLeakedDbConnections(label);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
if (timer)
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// after building for esm, importing pg using the following:
|
|
2
|
+
//
|
|
3
|
+
// import * as pg from 'pg'
|
|
4
|
+
//
|
|
5
|
+
// will crash. This is difficult to discover, since it only happens
|
|
6
|
+
// when being imported from our esm build.
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
8
|
+
// @ts-ignore
|
|
9
|
+
import pg from 'pg';
|
|
10
|
+
import { debuglog } from 'node:util';
|
|
11
|
+
import DreamApp from '../dream-app/index.js';
|
|
12
|
+
/**
|
|
13
|
+
* Opt-in connection-leak diagnostics.
|
|
14
|
+
*
|
|
15
|
+
* `closeAllConnectionsForConnectionName` bounds the shutdown drain, but when it
|
|
16
|
+
* times out it can only say *that* a pooled client was held past shutdown, not
|
|
17
|
+
* *where* it was acquired. Finding the offending query then means hand-patching
|
|
18
|
+
* `pg` — exactly the multi-hour spelunk this is meant to delete.
|
|
19
|
+
*
|
|
20
|
+
* Enable with `NODE_DEBUG=dream` (the same `node:util` debug-channel
|
|
21
|
+
* convention Psychic uses for `NODE_DEBUG=psychic`). Dev/CI only — it patches
|
|
22
|
+
* `pg.Pool.prototype.connect` process-wide and retains references to
|
|
23
|
+
* checked-out clients, so it is never installed unless explicitly asked for.
|
|
24
|
+
* When the close timeout fires, the acquire stack of every still-checked-out
|
|
25
|
+
* client is logged.
|
|
26
|
+
*
|
|
27
|
+
* Like the rest of the ecosystem's `debuglog` usage, the channel is resolved
|
|
28
|
+
* once by Node on first call and cached for the process lifetime — this is a
|
|
29
|
+
* process-level switch, not a runtime toggle.
|
|
30
|
+
*/
|
|
31
|
+
// Resolved once at module load (Node caches the channel), mirroring
|
|
32
|
+
// `const debugEnabled = debuglog('psychic').enabled` in Psychic.
|
|
33
|
+
const dreamDebugEnabled = debuglog('dream').enabled;
|
|
34
|
+
// Map (not WeakMap) so entries can be enumerated at report time. Entries are
|
|
35
|
+
// deleted on release, so only genuinely-leaked clients accumulate, and only
|
|
36
|
+
// when diagnostics are explicitly enabled.
|
|
37
|
+
const checkedOutClients = new Map();
|
|
38
|
+
const INSTALLED = Symbol.for('dream:dbLeakDiagnosticsInstalled');
|
|
39
|
+
let enabled = false;
|
|
40
|
+
function track(client, stack) {
|
|
41
|
+
if (!client)
|
|
42
|
+
return;
|
|
43
|
+
checkedOutClients.set(client, { stack: stack ?? '(no stack captured)', since: Date.now() });
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
const c = client;
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
47
|
+
if (c.__dreamReleasePatched)
|
|
48
|
+
return;
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
50
|
+
const originalRelease = c.release;
|
|
51
|
+
if (typeof originalRelease !== 'function')
|
|
52
|
+
return;
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
54
|
+
c.__dreamReleasePatched = true;
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
56
|
+
c.release = function patchedRelease(...args) {
|
|
57
|
+
checkedOutClients.delete(client);
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
|
|
59
|
+
return originalRelease.apply(this, args);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Idempotent. Patches `pg.Pool.prototype.connect` to stamp the caller's stack
|
|
64
|
+
* onto each leased client. No-op unless `DREAM_DB_LEAK_DIAGNOSTICS` is set.
|
|
65
|
+
*/
|
|
66
|
+
export function installDbConnectionLeakDiagnosticsIfEnabled() {
|
|
67
|
+
if (!dreamDebugEnabled)
|
|
68
|
+
return;
|
|
69
|
+
enabled = true;
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
|
|
71
|
+
const Pool = pg.Pool;
|
|
72
|
+
if (!Pool?.prototype)
|
|
73
|
+
return;
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
75
|
+
if (Pool.prototype[INSTALLED])
|
|
76
|
+
return;
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
78
|
+
Pool.prototype[INSTALLED] = true;
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
|
|
80
|
+
const originalConnect = Pool.prototype.connect;
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
82
|
+
Pool.prototype.connect = function patchedConnect(cb) {
|
|
83
|
+
const acquireStack = new Error('db connection acquired here').stack;
|
|
84
|
+
if (typeof cb === 'function') {
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
|
|
86
|
+
return originalConnect.call(this, (err, client, release) => {
|
|
87
|
+
track(client, acquireStack);
|
|
88
|
+
// `track()` replaces `client.release` with an untracking wrapper.
|
|
89
|
+
// Callback-style users call the 3rd `release` arg, which pg captured
|
|
90
|
+
// *before* that swap — so hand them the wrapped one instead, or a
|
|
91
|
+
// correctly-released client would be reported as a false leak.
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
|
93
|
+
const wrappedRelease = client ? client.release : release;
|
|
94
|
+
cb(err, client, wrappedRelease);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
|
98
|
+
return originalConnect.call(this).then((client) => {
|
|
99
|
+
track(client, acquireStack);
|
|
100
|
+
return client;
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* If diagnostics are enabled, log the acquire stack of every client still
|
|
106
|
+
* checked out. Called from the shutdown-drain timeout path.
|
|
107
|
+
*/
|
|
108
|
+
export function reportLeakedDbConnections(label) {
|
|
109
|
+
if (!enabled || checkedOutClients.size === 0)
|
|
110
|
+
return;
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const report = Array.from(checkedOutClients.values())
|
|
113
|
+
.map((entry, i) => ` [#${i}] held ${now - entry.since}ms\n${entry.stack}`)
|
|
114
|
+
.join('\n\n');
|
|
115
|
+
DreamApp.logWithLevel('warn', `[dream] connection-leak diagnostics: ${checkedOutClients.size} pg client(s) still checked out ` +
|
|
116
|
+
`when "${label}" hit the close timeout. Acquired at:\n\n${report}`);
|
|
117
|
+
}
|
|
@@ -92,16 +92,29 @@ export default class KyselyQueryDriver extends QueryDriverBase {
|
|
|
92
92
|
return _db(connectionName || 'default', dbConnectionType, this.dialectProvider(connectionName, dbConnectionType));
|
|
93
93
|
}
|
|
94
94
|
static dialectProvider(connectionName, dbConnectionType) {
|
|
95
|
-
return (connectionConf) =>
|
|
96
|
-
pool
|
|
95
|
+
return (connectionConf) => {
|
|
96
|
+
const pool = new pg.Pool({
|
|
97
|
+
// Spread pg passthrough first; Dream's resolved fields follow and
|
|
98
|
+
// always win (per-connection database name, TLS directive).
|
|
99
|
+
...(connectionConf.pg ?? {}),
|
|
97
100
|
user: connectionConf.user || '',
|
|
98
101
|
password: connectionConf.password || '',
|
|
99
102
|
database: DreamApp.getOrFail().dbName(connectionName, dbConnectionType),
|
|
100
103
|
host: connectionConf.host || 'localhost',
|
|
101
104
|
port: connectionConf.port || 5432,
|
|
102
105
|
ssl: resolvePostgresSsl(connectionConf),
|
|
103
|
-
})
|
|
104
|
-
|
|
106
|
+
});
|
|
107
|
+
// node-postgres explicitly warns: a Pool that emits 'error' with no
|
|
108
|
+
// listener crashes the Node process. Idle pooled clients emit 'error'
|
|
109
|
+
// on backend restart / failover / a load balancer reaping idle TCP.
|
|
110
|
+
// Log via Dream's logger instead of taking the process down; the pool
|
|
111
|
+
// discards the dead client and recovers on the next acquire.
|
|
112
|
+
pool.on('error', err => {
|
|
113
|
+
DreamApp.getOrFail().logger.error(`[dream] idle pg client error on connection "${connectionName}:${dbConnectionType}" ` +
|
|
114
|
+
`(handled — process kept alive): ${err.stack ?? String(err)}`);
|
|
115
|
+
});
|
|
116
|
+
return new PostgresDialect({ pool });
|
|
117
|
+
};
|
|
105
118
|
}
|
|
106
119
|
static async ensureAllMigrationsHaveBeenRun(connectionName) {
|
|
107
120
|
const migrationsNeedToBeRun = await checkForNeedToBeRunMigrations({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import DecryptionError from '../errors/encrypt/DecryptionError.js';
|
|
2
|
-
import
|
|
2
|
+
import DecryptionRotationError from '../errors/encrypt/DecryptionRotationError.js';
|
|
3
3
|
import MissingEncryptionKey from '../errors/encrypt/MissingEncryptionKey.js';
|
|
4
4
|
import decryptAESGCM from './algorithms/aes-gcm/decryptAESGCM.js';
|
|
5
5
|
import encryptAESGCM from './algorithms/aes-gcm/encryptAESGCM.js';
|
|
@@ -34,7 +34,7 @@ export default class Encrypt {
|
|
|
34
34
|
*
|
|
35
35
|
* **Three-arg form** (rotation): tries the current key first; on
|
|
36
36
|
* `DecryptionError` falls back to the legacy key. If both fail, throws
|
|
37
|
-
* `
|
|
37
|
+
* `DecryptionRotationError` carrying both per-key errors. A
|
|
38
38
|
* `DecryptionParseError` from the current key is **not** retried — the
|
|
39
39
|
* cipher already matched, so a parse failure means the encrypted format
|
|
40
40
|
* is wrong (an app bug), not a wrong key.
|
|
@@ -44,7 +44,7 @@ export default class Encrypt {
|
|
|
44
44
|
* @throws MissingEncryptionKey
|
|
45
45
|
* @throws DecryptionError
|
|
46
46
|
* @throws DecryptionParseError
|
|
47
|
-
* @throws
|
|
47
|
+
* @throws DecryptionRotationError
|
|
48
48
|
*/
|
|
49
49
|
static decrypt(encrypted, { algorithm, key }, legacyOpts) {
|
|
50
50
|
if (legacyOpts)
|
|
@@ -81,7 +81,7 @@ export default class Encrypt {
|
|
|
81
81
|
catch (err) {
|
|
82
82
|
if (!(err instanceof DecryptionError))
|
|
83
83
|
throw err;
|
|
84
|
-
throw new
|
|
84
|
+
throw new DecryptionRotationError(currentKeyError, err);
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
/**
|
|
@@ -117,7 +117,7 @@ export default class Encrypt {
|
|
|
117
117
|
* not forced to re-authenticate.
|
|
118
118
|
* - For `@Encrypted` columns: until every existing row has been
|
|
119
119
|
* re-encrypted under the new key. Dropping `legacy` early will cause
|
|
120
|
-
* `
|
|
120
|
+
* `DecryptionRotationError` on any not-yet-rewritten row.
|
|
121
121
|
*/
|
|
122
122
|
static generateKey(algorithm) {
|
|
123
123
|
switch (algorithm) {
|
|
@@ -7,6 +7,7 @@ import absoluteDreamPath from '../path/absoluteDreamPath.js';
|
|
|
7
7
|
import snakeify from '../snakeify.js';
|
|
8
8
|
import standardizeFullyQualifiedModelName from '../standardizeFullyQualifiedModelName.js';
|
|
9
9
|
import uniq from '../uniq.js';
|
|
10
|
+
import parseAttribute from './parseAttribute.js';
|
|
10
11
|
/**
|
|
11
12
|
* Column names that are automatically emitted by the model generator (and
|
|
12
13
|
* the migration generator). When the user passes any of these explicitly,
|
|
@@ -35,8 +36,17 @@ export default function generateDreamContent(options) {
|
|
|
35
36
|
const columnsForAttributes = config.isSTI
|
|
36
37
|
? options.columnsWithTypes
|
|
37
38
|
: filterAutoGeneratedTimestampColumns(options.columnsWithTypes);
|
|
38
|
-
const baseImports = createImportConfig(config, options, { includeSoftDelete });
|
|
39
39
|
const attributesResult = processAttributes(columnsForAttributes, config.modelClassName);
|
|
40
|
+
// Decorators is only referenced via `@deco.*` annotations on fields (e.g.,
|
|
41
|
+
// BelongsTo, Encrypted). If the generated body emits none of those, the
|
|
42
|
+
// import + declaration would trigger lint errors for unused identifiers,
|
|
43
|
+
// so emit them commented out instead — keeps the boilerplate present for
|
|
44
|
+
// the developer to uncomment when they add their first decorator.
|
|
45
|
+
const decoratorsInUse = attributesResult.formattedDecorators.length > 0;
|
|
46
|
+
const baseImports = createImportConfig(config, options, {
|
|
47
|
+
includeSoftDelete,
|
|
48
|
+
includeDecorators: decoratorsInUse,
|
|
49
|
+
});
|
|
40
50
|
const allImports = {
|
|
41
51
|
...baseImports,
|
|
42
52
|
modelImportStatements: [...baseImports.modelImportStatements, ...attributesResult.imports],
|
|
@@ -48,9 +58,14 @@ export default function generateDreamContent(options) {
|
|
|
48
58
|
const fieldsSection = buildFieldsSection(config, attributesResult, {
|
|
49
59
|
includeDeletedAt: includeSoftDelete || hasExplicitDeletedAt,
|
|
50
60
|
});
|
|
61
|
+
const decoBlock = decoratorsInUse
|
|
62
|
+
? `const deco = new Decorators<typeof ${config.modelClassName}>()`
|
|
63
|
+
: `// Uncomment when adding decorators (@deco.BelongsTo, @deco.Validates, etc.):
|
|
64
|
+
// import { Decorators } from '@rvoh/dream'
|
|
65
|
+
// const deco = new Decorators<typeof ${config.modelClassName}>()`;
|
|
51
66
|
return `${importSection}
|
|
52
67
|
|
|
53
|
-
|
|
68
|
+
${decoBlock}
|
|
54
69
|
|
|
55
70
|
${classDeclaration}
|
|
56
71
|
${tableMethod}${serializersMethod}${fieldsSection}
|
|
@@ -78,9 +93,11 @@ export function createModelConfig(options) {
|
|
|
78
93
|
tableName,
|
|
79
94
|
};
|
|
80
95
|
}
|
|
81
|
-
export function createImportConfig(config, options, { includeSoftDelete }) {
|
|
96
|
+
export function createImportConfig(config, options, { includeSoftDelete, includeDecorators = true }) {
|
|
82
97
|
const dreamTypeImports = ['DreamColumn'];
|
|
83
|
-
const dreamImports = [
|
|
98
|
+
const dreamImports = [];
|
|
99
|
+
if (includeDecorators)
|
|
100
|
+
dreamImports.push('Decorators');
|
|
84
101
|
if (options.serializer) {
|
|
85
102
|
dreamTypeImports.push('DreamSerializers');
|
|
86
103
|
}
|
|
@@ -119,32 +136,41 @@ export function processAttributes(columnsWithTypes, modelClassName) {
|
|
|
119
136
|
};
|
|
120
137
|
}
|
|
121
138
|
export function processAttribute(attribute, modelClassName) {
|
|
122
|
-
const [
|
|
123
|
-
if (
|
|
139
|
+
const [rawName, rawType] = attribute.split(':');
|
|
140
|
+
if (rawName === undefined)
|
|
124
141
|
return { content: '', imports: [] };
|
|
125
|
-
if (!
|
|
126
|
-
throw new Error(`must pass a column type for ${
|
|
142
|
+
if (!rawType) {
|
|
143
|
+
throw new Error(`must pass a column type for ${rawName} (i.e. ${rawName}:string)`);
|
|
127
144
|
}
|
|
128
|
-
const
|
|
129
|
-
|
|
145
|
+
const parsed = parseAttribute(attribute);
|
|
146
|
+
// Malformed-but-parseable shapes (e.g., `Model@:belongs_to` with an empty
|
|
147
|
+
// alias) yield no content rather than throwing — the call site has already
|
|
148
|
+
// validated the basic name/type segments above.
|
|
149
|
+
if (!parsed)
|
|
150
|
+
return { content: '', imports: [] };
|
|
151
|
+
switch (parsed.normalizedAttributeType) {
|
|
130
152
|
case 'belongsto':
|
|
131
|
-
return createBelongsToAttribute(
|
|
153
|
+
return createBelongsToAttribute(parsed.rawAttributeName, modelClassName, {
|
|
154
|
+
aliasName: parsed.aliasName,
|
|
155
|
+
isOptional: parsed.isOptional,
|
|
156
|
+
});
|
|
132
157
|
case 'hasone':
|
|
133
158
|
case 'hasmany':
|
|
134
159
|
return { content: '', imports: [] };
|
|
135
160
|
case 'encrypted':
|
|
136
|
-
return createEncryptedAttribute(
|
|
161
|
+
return createEncryptedAttribute(parsed.rawAttributeName, attribute, modelClassName);
|
|
137
162
|
default:
|
|
138
|
-
return createRegularAttribute(
|
|
163
|
+
return createRegularAttribute(parsed.rawAttributeName, attribute, modelClassName);
|
|
139
164
|
}
|
|
140
165
|
}
|
|
141
|
-
export function createBelongsToAttribute(
|
|
142
|
-
const fullyQualifiedAssociatedModelName = standardizeFullyQualifiedModelName(
|
|
166
|
+
export function createBelongsToAttribute(fullyQualifiedModelInput, modelClassName, { aliasName, isOptional = false, } = {}) {
|
|
167
|
+
const fullyQualifiedAssociatedModelName = standardizeFullyQualifiedModelName(fullyQualifiedModelInput);
|
|
143
168
|
const associationModelName = globalClassNameFromFullyQualifiedModelName(fullyQualifiedAssociatedModelName);
|
|
144
169
|
const associationImportStatement = importStatementForModel(fullyQualifiedAssociatedModelName);
|
|
145
|
-
const associationName =
|
|
170
|
+
const associationName = aliasName
|
|
171
|
+
? camelize(aliasName)
|
|
172
|
+
: camelize(fullyQualifiedAssociatedModelName.split('/').pop());
|
|
146
173
|
const associationForeignKey = `${associationName}Id`;
|
|
147
|
-
const isOptional = descriptors.includes('optional');
|
|
148
174
|
const content = `
|
|
149
175
|
@deco.BelongsTo('${fullyQualifiedAssociatedModelName}', { on: '${associationForeignKey}'${isOptional ? ', optional: true' : ''} })
|
|
150
176
|
public ${associationName}: ${associationModelName}${isOptional ? ' | null' : ''}
|
|
@@ -17,14 +17,23 @@ export default function generateFactoryContent({ fullyQualifiedModelName, column
|
|
|
17
17
|
const attributeDefaults = [];
|
|
18
18
|
let counterVariableIncremented = false;
|
|
19
19
|
for (const attribute of columnsWithTypes) {
|
|
20
|
-
const [
|
|
21
|
-
if (
|
|
20
|
+
const [rawSegmentOne, _attributeType, ...descriptors] = attribute.split(':');
|
|
21
|
+
if (rawSegmentOne === undefined)
|
|
22
22
|
continue;
|
|
23
23
|
if (_attributeType === undefined)
|
|
24
24
|
continue;
|
|
25
25
|
const optional = optionalFromDescriptors(descriptors);
|
|
26
26
|
if (optional)
|
|
27
27
|
continue;
|
|
28
|
+
// Extract optional `@alias` from segment-1 (Model@alias:belongs_to form).
|
|
29
|
+
// Non-association tokens never contain `@`, so this is a no-op for scalars.
|
|
30
|
+
const atIdx = rawSegmentOne.indexOf('@');
|
|
31
|
+
const aliasName = atIdx !== -1 ? rawSegmentOne.slice(atIdx + 1) : undefined;
|
|
32
|
+
const attributeName = atIdx !== -1 ? rawSegmentOne.slice(0, atIdx) : rawSegmentOne;
|
|
33
|
+
if (!attributeName)
|
|
34
|
+
continue;
|
|
35
|
+
if (atIdx !== -1 && !aliasName)
|
|
36
|
+
continue;
|
|
28
37
|
const attributeVariable = camelize(attributeName.replace(/\//g, ''));
|
|
29
38
|
if (/^type$/.test(attributeName))
|
|
30
39
|
continue;
|
|
@@ -36,7 +45,9 @@ export default function generateFactoryContent({ fullyQualifiedModelName, column
|
|
|
36
45
|
const safeAttributeType = camelize(attributeType)?.toLowerCase();
|
|
37
46
|
switch (safeAttributeType) {
|
|
38
47
|
case 'belongsto': {
|
|
39
|
-
|
|
48
|
+
// When `Model@alias:belongs_to`, the factory property uses the alias;
|
|
49
|
+
// otherwise it uses the model's last namespace segment (legacy form).
|
|
50
|
+
const attributeVariable = aliasName ? camelize(aliasName) : camelize(attributeName.split('/').pop());
|
|
40
51
|
const fullyQualifiedAssociatedModelName = standardizeFullyQualifiedModelName(attributeName);
|
|
41
52
|
const associationModelName = globalClassNameFromFullyQualifiedModelName(fullyQualifiedAssociatedModelName);
|
|
42
53
|
const associationFactoryImportStatement = `import create${associationModelName} from '${absoluteDreamPath('factories', fullyQualifiedAssociatedModelName)}'`;
|
|
@@ -66,11 +77,27 @@ export default function generateFactoryContent({ fullyQualifiedModelName, column
|
|
|
66
77
|
counterVariableIncremented = true;
|
|
67
78
|
break;
|
|
68
79
|
case 'enum':
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
case 'enum[]': {
|
|
81
|
+
// When the user passed `name:enum:enum_type_name` (reuse form, no
|
|
82
|
+
// inline values), descriptors has exactly one element — the enum
|
|
83
|
+
// type name. The factory cannot see the enum's values, so emit a
|
|
84
|
+
// TS-rejecting placeholder rather than the type name itself (the
|
|
85
|
+
// previous behavior emitted `'enum_type_name'` as the literal value,
|
|
86
|
+
// which compiles in the factory but fails at runtime).
|
|
87
|
+
const isReuseWithoutValues = descriptors.length === 1;
|
|
88
|
+
const isArrayEnum = safeAttributeType === 'enum[]';
|
|
89
|
+
if (isReuseWithoutValues) {
|
|
90
|
+
const enumTypeName = descriptors[0];
|
|
91
|
+
const placeholder = isArrayEnum ? `['TODO']` : `'TODO'`;
|
|
92
|
+
attributeDefaults.push(`// TODO: replace with a value from the \`${enumTypeName}\` enum\n ${attributeVariable}: ${placeholder},`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const firstValue = (descriptors.at(-1) || '<tbd>').split(',')[0];
|
|
96
|
+
const literal = isArrayEnum ? `['${firstValue}']` : `'${firstValue}'`;
|
|
97
|
+
attributeDefaults.push(`${attributeVariable}: ${literal},`);
|
|
98
|
+
}
|
|
73
99
|
break;
|
|
100
|
+
}
|
|
74
101
|
case 'integer':
|
|
75
102
|
attributeDefaults.push(`${attributeVariable}: 1,`);
|
|
76
103
|
break;
|
|
@@ -27,9 +27,19 @@ export default function generateMigrationContent({ connectionName = 'default', t
|
|
|
27
27
|
const emitDeletedAtColumn = !altering && (softDelete || userExplicitlyPassedDeletedAt);
|
|
28
28
|
const { columnDefs, columnDrops, indexDefs, indexDrops } = processedColumnsWithTypes.reduce((acc, attributeDeclaration) => {
|
|
29
29
|
const { columnDefs, columnDrops, indexDefs, indexDrops } = acc;
|
|
30
|
-
const [
|
|
30
|
+
const [rawSegmentOne, _attributeType, ...descriptors] = attributeDeclaration.split(':');
|
|
31
|
+
if (!rawSegmentOne)
|
|
32
|
+
return acc;
|
|
33
|
+
// Extract optional `@alias` from segment-1 (Model@alias:belongs_to form).
|
|
34
|
+
// The model name (without alias) is what gets standardized / referenced
|
|
35
|
+
// by table lookup; the alias drives the column / index naming when present.
|
|
36
|
+
const atIdx = rawSegmentOne.indexOf('@');
|
|
37
|
+
const aliasName = atIdx !== -1 ? rawSegmentOne.slice(atIdx + 1) : undefined;
|
|
38
|
+
const nonStandardAttributeName = atIdx !== -1 ? rawSegmentOne.slice(0, atIdx) : rawSegmentOne;
|
|
31
39
|
if (!nonStandardAttributeName)
|
|
32
40
|
return acc;
|
|
41
|
+
if (atIdx !== -1 && !aliasName)
|
|
42
|
+
return acc;
|
|
33
43
|
/**
|
|
34
44
|
* Automatically set email columns to citext since different casings of
|
|
35
45
|
* email address are the same email address
|
|
@@ -64,8 +74,14 @@ export default function generateMigrationContent({ connectionName = 'default', t
|
|
|
64
74
|
primaryKeyType,
|
|
65
75
|
omitInlineNonNull,
|
|
66
76
|
originalAssociationName: nonStandardAttributeName,
|
|
77
|
+
aliasName,
|
|
67
78
|
}));
|
|
68
|
-
|
|
79
|
+
// Resolve the actual column name used for index + drop emission.
|
|
80
|
+
// When an alias is present (Model@alias:belongs_to), the column is
|
|
81
|
+
// `${alias}_id`; otherwise it's derived from the model's last segment.
|
|
82
|
+
attributeName = aliasName
|
|
83
|
+
? snakeify(aliasName)
|
|
84
|
+
: snakeify(nonStandardAttributeName.split('/').pop());
|
|
69
85
|
attributeName = associationNameToForeignKey(attributeName);
|
|
70
86
|
break;
|
|
71
87
|
case 'enum':
|
|
@@ -268,6 +284,7 @@ function generateColumnStr(attributeName, attributeType, descriptors, { omitInli
|
|
|
268
284
|
const isUnique = /(email|token|uuid)$/.test(attributeName);
|
|
269
285
|
const hasExtraValues = providedDefault || notNull || isUnique;
|
|
270
286
|
const isArray = /\[\]$/.test(attributeType);
|
|
287
|
+
const needsJsonDefault = notNull && !isArray && (attributeType === 'jsonb' || attributeType === 'json');
|
|
271
288
|
if (hasExtraValues)
|
|
272
289
|
returnStr += ', col => col';
|
|
273
290
|
if (notNull)
|
|
@@ -278,6 +295,12 @@ function generateColumnStr(attributeName, attributeType, descriptors, { omitInli
|
|
|
278
295
|
returnStr += `.defaultTo('${providedDefault}')`;
|
|
279
296
|
else if (isArray)
|
|
280
297
|
returnStr += `.defaultTo('{}')`;
|
|
298
|
+
// jsonb / json columns get an empty-object default so calling create() on a
|
|
299
|
+
// model without explicitly setting the column doesn't trip NOT NULL. Mirrors
|
|
300
|
+
// the existing boolean → false and array → '{}' auto-defaults. Optional
|
|
301
|
+
// jsonb columns skip the default since null is the intended initial state.
|
|
302
|
+
else if (needsJsonDefault)
|
|
303
|
+
returnStr += `.defaultTo(sql\`'{}'::${attributeType}\`)`;
|
|
281
304
|
returnStr = `${returnStr})`;
|
|
282
305
|
if (attributeName === STI_TYPE_COLUMN_NAME)
|
|
283
306
|
returnStr = `// CONSIDER: when using type for STI, always use an enum
|
|
@@ -306,11 +329,14 @@ function attributeTypeString(attributeType) {
|
|
|
306
329
|
}
|
|
307
330
|
}
|
|
308
331
|
}
|
|
309
|
-
function generateBelongsToStr(connectionName, associationName, { primaryKeyType, omitInlineNonNull: optional = false, originalAssociationName, }) {
|
|
332
|
+
function generateBelongsToStr(connectionName, associationName, { primaryKeyType, omitInlineNonNull: optional = false, originalAssociationName, aliasName, }) {
|
|
310
333
|
const dbDriverClass = Query.dbDriverClass(connectionName);
|
|
311
334
|
const dataType = dbDriverClass.foreignKeyTypeFromPrimaryKey(primaryKeyType);
|
|
312
335
|
const references = lookupReferencesTable(associationName, originalAssociationName);
|
|
313
|
-
|
|
336
|
+
// When the user passed `Model@alias:belongs_to`, the column name comes from
|
|
337
|
+
// the alias; otherwise it's the model's last segment (existing behavior).
|
|
338
|
+
const columnNameSource = aliasName ? snakeify(aliasName) : associationName.split('/').pop();
|
|
339
|
+
return `.addColumn('${associationNameToForeignKey(columnNameSource)}', '${dataType}', col => col.references('${references}.id').onDelete('restrict')${optional ? '' : '.notNull()'})`;
|
|
314
340
|
}
|
|
315
341
|
function generateIdStr({ primaryKeyType }) {
|
|
316
342
|
switch (primaryKeyType) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import camelize from '../camelize.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a single `columnsWithTypes` CLI token into its structural pieces.
|
|
4
|
+
*
|
|
5
|
+
* Centralizes the splitting + normalization logic shared across the model
|
|
6
|
+
* generator (`generateDreamContent`), the migration generator
|
|
7
|
+
* (`generateMigrationContent`), the factory generator
|
|
8
|
+
* (`generateFactoryContent`), and Psychic's resource/controller generators.
|
|
9
|
+
* Keeps the shared layer thin: consumers handle their own coercions (e.g.,
|
|
10
|
+
* migration's `email$ → citext`) and filters (e.g., Psychic's `_type`/`_id`
|
|
11
|
+
* exclusion).
|
|
12
|
+
*
|
|
13
|
+
* Returns `null` for malformed tokens (missing name or type).
|
|
14
|
+
*
|
|
15
|
+
* Supported forms (segment-1 only — see `rawAttributeType` for the full
|
|
16
|
+
* vocabulary of types/associations recognized by individual generators):
|
|
17
|
+
*
|
|
18
|
+
* - `name:type` → standard column
|
|
19
|
+
* - `name:type:optional` → nullable column
|
|
20
|
+
* - `Model:belongs_to[:optional]` → association with model-derived alias
|
|
21
|
+
* - `Model@alias:belongs_to[:optional]` → association with explicit alias
|
|
22
|
+
*/
|
|
23
|
+
export default function parseAttribute(attribute) {
|
|
24
|
+
const segments = attribute.split(':');
|
|
25
|
+
const rawSegmentOne = segments[0];
|
|
26
|
+
const rawAttributeType = segments[1];
|
|
27
|
+
const descriptors = segments.slice(2);
|
|
28
|
+
if (!rawSegmentOne || !rawAttributeType)
|
|
29
|
+
return null;
|
|
30
|
+
// Split segment-1 on `@` to extract an optional alias. Empty alias after `@`
|
|
31
|
+
// (e.g., `Model@:belongs_to`) is treated as malformed.
|
|
32
|
+
let rawAttributeName = rawSegmentOne;
|
|
33
|
+
let aliasName;
|
|
34
|
+
const atIndex = rawSegmentOne.indexOf('@');
|
|
35
|
+
if (atIndex !== -1) {
|
|
36
|
+
rawAttributeName = rawSegmentOne.slice(0, atIndex);
|
|
37
|
+
const rawAlias = rawSegmentOne.slice(atIndex + 1);
|
|
38
|
+
if (!rawAttributeName || !rawAlias)
|
|
39
|
+
return null;
|
|
40
|
+
aliasName = rawAlias;
|
|
41
|
+
}
|
|
42
|
+
// Pop trailing `optional` keyword off the descriptors list. Mirrors
|
|
43
|
+
// `optionalFromDescriptors` in generateMigrationContent.ts so the keyword
|
|
44
|
+
// behaves identically regardless of which consumer parses the token.
|
|
45
|
+
let isOptional = false;
|
|
46
|
+
if (descriptors[descriptors.length - 1] === 'optional') {
|
|
47
|
+
descriptors.pop();
|
|
48
|
+
isOptional = true;
|
|
49
|
+
}
|
|
50
|
+
const normalizedAttributeType = camelize(rawAttributeType).toLowerCase();
|
|
51
|
+
const isArray = /\[\]$/.test(rawAttributeType);
|
|
52
|
+
return {
|
|
53
|
+
rawAttributeName,
|
|
54
|
+
aliasName,
|
|
55
|
+
rawAttributeType,
|
|
56
|
+
normalizedAttributeType,
|
|
57
|
+
descriptors,
|
|
58
|
+
isOptional,
|
|
59
|
+
isArray,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -7,6 +7,9 @@ export { default as DataIncompatibleWithDatabaseField } from '../errors/db/DataI
|
|
|
7
7
|
export { default as DataTypeColumnTypeMismatch } from '../errors/db/DataTypeColumnTypeMismatch.js';
|
|
8
8
|
export { default as NotNullViolation } from '../errors/db/NotNullViolation.js';
|
|
9
9
|
export { default as GlobalNameNotSet } from '../errors/dream-app/GlobalNameNotSet.js';
|
|
10
|
+
export { default as DecryptionError } from '../errors/encrypt/DecryptionError.js';
|
|
11
|
+
export { default as DecryptionParseError } from '../errors/encrypt/DecryptionParseError.js';
|
|
12
|
+
export { default as DecryptionRotationError } from '../errors/encrypt/DecryptionRotationError.js';
|
|
10
13
|
export { default as RecordNotFound } from '../errors/RecordNotFound.js';
|
|
11
14
|
export { default as MissingSerializersDefinition } from '../errors/serializers/MissingSerializersDefinition.js';
|
|
12
15
|
export { default as ValidationError } from '../errors/ValidationError.js';
|