@stacksjs/database 0.75.177 → 0.75.178
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/datetime-columns.d.ts +48 -0
- package/dist/datetime-columns.js +8 -2
- package/package.json +16 -16
|
@@ -20,6 +20,47 @@ export declare function findTimestampColumns(): Promise<TimestampColumn[]>;
|
|
|
20
20
|
* being spliced into DDL, which cannot take a placeholder.
|
|
21
21
|
*/
|
|
22
22
|
export declare function modifyToDatetimeSql(column: TimestampColumn): string;
|
|
23
|
+
/** Whether an operator asked for the conversion rather than the report. */
|
|
24
|
+
export declare function convertAwareTimestampsRequested(env?: Record<string, string | undefined>): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Framework columns Postgres holds as `timestamp with time zone`.
|
|
27
|
+
*
|
|
28
|
+
* The framework writes a naive literal (`sqlDateTime()`, no offset) and
|
|
29
|
+
* Postgres interprets one written into an aware column in the SESSION
|
|
30
|
+
* timezone, so on any server whose zone is not UTC the stored instant is
|
|
31
|
+
* shifted by that offset. Measured: with the session zone at `Asia/Manila`,
|
|
32
|
+
* `2026-10-14T12:00:00.000` into `timestamptz` reads back as
|
|
33
|
+
* `2026-10-14T04:00:00.000Z`, and that is enough to make token issuance fail
|
|
34
|
+
* with `Failed to persist the requested access token`, because the
|
|
35
|
+
* read-back check compares the stored expiry against the instant it asked for
|
|
36
|
+
* and the two genuinely differ (stacksjs/stacks#2902).
|
|
37
|
+
*
|
|
38
|
+
* The table creators declare `timestamp without time zone`, so a database the
|
|
39
|
+
* framework built has none of these. One appears from a hand-written
|
|
40
|
+
* migration, an older corpus, or a restore from a database that had them.
|
|
41
|
+
*/
|
|
42
|
+
export declare function findAwareTimestampColumns(): Promise<AwareTimestampColumn[]>;
|
|
43
|
+
/**
|
|
44
|
+
* The `ALTER` that makes one aware column naive.
|
|
45
|
+
*
|
|
46
|
+
* The cast reads the stored instant through the session timezone, which is the
|
|
47
|
+
* same interpretation applied when the naive literal was written, so what
|
|
48
|
+
* lands in the naive column is the literal the application originally wrote.
|
|
49
|
+
* Rows written under a *different* session zone - a server that moved, or a
|
|
50
|
+
* DST boundary - were already inconsistent with each other and no conversion
|
|
51
|
+
* recovers them. Exactly the reasoning the MySQL pass above rests on.
|
|
52
|
+
*
|
|
53
|
+
* Identifiers come from `information_schema` and are re-validated here before
|
|
54
|
+
* being spliced into DDL, which cannot take a placeholder.
|
|
55
|
+
*/
|
|
56
|
+
export declare function convertToNaiveSql(column: AwareTimestampColumn): string;
|
|
57
|
+
/**
|
|
58
|
+
* What an operator is told when a framework table holds an aware column.
|
|
59
|
+
*
|
|
60
|
+
* Reported rather than converted, because the repair is DDL on a column that
|
|
61
|
+
* may be large and is someone else's data. The flag converts.
|
|
62
|
+
*/
|
|
63
|
+
export declare function formatAwareTimestampWarning(columns: readonly AwareTimestampColumn[]): string;
|
|
23
64
|
/**
|
|
24
65
|
* Convert every remaining `TIMESTAMP` column on a framework table to
|
|
25
66
|
* `DATETIME`. MySQL-only and idempotent — once converted the
|
|
@@ -27,6 +68,8 @@ export declare function modifyToDatetimeSql(column: TimestampColumn): string;
|
|
|
27
68
|
* SELECT.
|
|
28
69
|
*/
|
|
29
70
|
export declare function ensureUtcDatetimeColumns(options?: { verbose?: boolean }): Promise<{ success: boolean, converted: number, error?: string }>;
|
|
71
|
+
/** Environment escape hatch: convert the columns instead of reporting them. */
|
|
72
|
+
export declare const AWARE_TIMESTAMP_CONVERSION_ENV: 'STACKS_CONVERT_AWARE_TIMESTAMPS';
|
|
30
73
|
declare interface TimestampColumn {
|
|
31
74
|
table: string
|
|
32
75
|
column: string
|
|
@@ -34,3 +77,8 @@ declare interface TimestampColumn {
|
|
|
34
77
|
columnDefault: string | null
|
|
35
78
|
extra: string
|
|
36
79
|
}
|
|
80
|
+
/** A framework column Postgres stores WITH a timezone, against the convention. */
|
|
81
|
+
export declare interface AwareTimestampColumn {
|
|
82
|
+
table: string
|
|
83
|
+
column: string
|
|
84
|
+
}
|
package/dist/datetime-columns.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import{log}from"@stacksjs/logging";import{db}from"./utils";import{dialectCapabilities,resolvedDatabaseDriver}from"./dialect";import{traitTableNames}from"./trait-tables";export function frameworkDatetimeTables(){return[...traitTableNames(),"passkeys","password_resets","email_verifications","oauth_clients","oauth_access_tokens","oauth_refresh_tokens","two_factor_challenges","two_factor_pending_secrets","webauthn_challenges","roles","permissions","user_roles","user_permissions","role_permissions","notifications","notification_preferences","notification_deliveries"]}export async function findTimestampColumns(){const tables=frameworkDatetimeTables(),placeholders=tables.map(()=>"?").join(", ");return(await db.unsafe(`SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT, EXTRA
|
|
1
|
+
import process from"node:process";import{log}from"@stacksjs/logging";import{db}from"./utils";import{dialectCapabilities,resolvedDatabaseDriver}from"./dialect";import{traitTableNames}from"./trait-tables";export function frameworkDatetimeTables(){return[...traitTableNames(),"passkeys","password_resets","email_verifications","oauth_clients","oauth_access_tokens","oauth_refresh_tokens","two_factor_challenges","two_factor_pending_secrets","webauthn_challenges","roles","permissions","user_roles","user_permissions","role_permissions","notifications","notification_preferences","notification_deliveries"]}export async function findTimestampColumns(){const tables=frameworkDatetimeTables(),placeholders=tables.map(()=>"?").join(", ");return(await db.unsafe(`SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT, EXTRA
|
|
2
2
|
FROM information_schema.COLUMNS
|
|
3
3
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
4
4
|
AND TABLE_NAME IN (${placeholders})
|
|
5
5
|
AND (
|
|
6
6
|
DATA_TYPE = 'timestamp'
|
|
7
7
|
OR (DATA_TYPE = 'varchar' AND COLUMN_NAME IN ('created_at', 'updated_at'))
|
|
8
|
-
)`,tables).execute()).map((row)=>({table:String(row.TABLE_NAME??row.table_name),column:String(row.COLUMN_NAME??row.column_name),nullable:String(row.IS_NULLABLE??row.is_nullable).toUpperCase()==="YES",columnDefault:row.COLUMN_DEFAULT??row.column_default??null,extra:String(row.EXTRA??row.extra??"")}))}export function modifyToDatetimeSql(column){const safe=/^[a-z_]\w*$/i;if(!safe.test(column.table)||!safe.test(column.column))throw Error(`[datetime-columns] Refusing to alter unsafe identifier: ${column.table}.${column.column}`);let ddl=`ALTER TABLE \`${column.table}\` MODIFY \`${column.column}\` DATETIME`;ddl+=column.nullable?" NULL":" NOT NULL";if(column.columnDefault!==null){const isFunction=/^CURRENT_TIMESTAMP(\(\d*\))?$/i.test(String(column.columnDefault));ddl+=isFunction?` DEFAULT ${column.columnDefault}`:` DEFAULT '${String(column.columnDefault).replace(/'/g,"''")}'`}if(/on update CURRENT_TIMESTAMP/i.test(column.extra))ddl+=" ON UPDATE CURRENT_TIMESTAMP";return ddl}export
|
|
8
|
+
)`,tables).execute()).map((row)=>({table:String(row.TABLE_NAME??row.table_name),column:String(row.COLUMN_NAME??row.column_name),nullable:String(row.IS_NULLABLE??row.is_nullable).toUpperCase()==="YES",columnDefault:row.COLUMN_DEFAULT??row.column_default??null,extra:String(row.EXTRA??row.extra??"")}))}export function modifyToDatetimeSql(column){const safe=/^[a-z_]\w*$/i;if(!safe.test(column.table)||!safe.test(column.column))throw Error(`[datetime-columns] Refusing to alter unsafe identifier: ${column.table}.${column.column}`);let ddl=`ALTER TABLE \`${column.table}\` MODIFY \`${column.column}\` DATETIME`;ddl+=column.nullable?" NULL":" NOT NULL";if(column.columnDefault!==null){const isFunction=/^CURRENT_TIMESTAMP(\(\d*\))?$/i.test(String(column.columnDefault));ddl+=isFunction?` DEFAULT ${column.columnDefault}`:` DEFAULT '${String(column.columnDefault).replace(/'/g,"''")}'`}if(/on update CURRENT_TIMESTAMP/i.test(column.extra))ddl+=" ON UPDATE CURRENT_TIMESTAMP";return ddl}export const AWARE_TIMESTAMP_CONVERSION_ENV="STACKS_CONVERT_AWARE_TIMESTAMPS";export function convertAwareTimestampsRequested(env=process.env){const raw=env[AWARE_TIMESTAMP_CONVERSION_ENV]?.trim().toLowerCase();return raw==="1"||raw==="true"}export async function findAwareTimestampColumns(){const tables=frameworkDatetimeTables(),placeholders=tables.map((_,index)=>`$${index+1}`).join(", ");return(await db.unsafe(`SELECT table_name, column_name
|
|
9
|
+
FROM information_schema.columns
|
|
10
|
+
WHERE table_schema = current_schema()
|
|
11
|
+
AND table_name IN (${placeholders})
|
|
12
|
+
AND data_type = 'timestamp with time zone'
|
|
13
|
+
ORDER BY table_name, column_name`,tables).execute()).map((row)=>({table:String(row.table_name??row.TABLE_NAME),column:String(row.column_name??row.COLUMN_NAME)}))}export function convertToNaiveSql(column){const safe=/^[a-z_]\w*$/i;if(!safe.test(column.table)||!safe.test(column.column))throw Error(`[datetime-columns] Refusing to alter unsafe identifier: ${column.table}.${column.column}`);return`ALTER TABLE "${column.table}" ALTER COLUMN "${column.column}" TYPE timestamp USING "${column.column}"::timestamp`}export function formatAwareTimestampWarning(columns){const sample=columns.slice(0,5).map((column)=>` ${column.table}.${column.column}`);return[`[datetime-columns] ${columns.length} framework column(s) are \`timestamp with time zone\`, which this framework does not write correctly:`,...sample,columns.length>sample.length?` ... and ${columns.length-sample.length} more`:"","","The framework writes a naive UTC literal, and Postgres reads one written into an aware column in the session","timezone, so every timestamp in these columns is shifted by that offset. A read-back check comparing a stored","expiry against the instant it asked for then fails, which surfaces as a 500 about persistence (#2902).","",`Convert them in place, preserving what the application wrote: re-run with ${AWARE_TIMESTAMP_CONVERSION_ENV}=1, or`,"by hand:",...columns.slice(0,5).map((column)=>` ${convertToNaiveSql(column)};`)].filter((line)=>line!=="").join(`
|
|
14
|
+
`)}export async function ensureUtcDatetimeColumns(options={}){const driver=resolvedDatabaseDriver(),wire=dialectCapabilities(driver).wire;if(wire==="postgres")return await ensureNaiveTimestampColumns(options);if(wire!=="mysql")return{success:!0,converted:0};try{const columns=await findTimestampColumns();if(columns.length===0){if(options.verbose)log.info("No TIMESTAMP columns left to convert");return{success:!0,converted:0}}for(const column of columns){if(options.verbose)log.info(`Converting ${column.table}.${column.column} to DATETIME...`);await db.unsafe(modifyToDatetimeSql(column)).execute()}log.debug(`[datetime-columns] Converted ${columns.length} TIMESTAMP column(s) to DATETIME`);return{success:!0,converted:columns.length}}catch(error){const message=error instanceof Error?error.message:String(error);log.error(`Failed to convert TIMESTAMP columns to DATETIME: ${message}`);return{success:!1,converted:0,error:message}}}async function ensureNaiveTimestampColumns(options){try{const columns=await findAwareTimestampColumns();if(columns.length===0){if(options.verbose)log.info("No aware timestamp columns on framework tables");return{success:!0,converted:0}}if(!convertAwareTimestampsRequested()){log.warn(formatAwareTimestampWarning(columns));return{success:!0,converted:0}}for(const column of columns){if(options.verbose)log.info(`Converting ${column.table}.${column.column} to a naive timestamp...`);await db.unsafe(convertToNaiveSql(column)).execute()}log.debug(`[datetime-columns] Converted ${columns.length} aware timestamp column(s) to naive`);return{success:!0,converted:columns.length}}catch(error){const message=error instanceof Error?error.message:String(error);log.error(`Failed to inspect aware timestamp columns: ${message}`);return{success:!1,converted:0,error:message}}}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/database",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.178",
|
|
6
6
|
"description": "The Stacks database integration.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -66,26 +66,26 @@
|
|
|
66
66
|
"prepublishOnly": "bun run build"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
|
-
"@stacksjs/config": "0.75.
|
|
70
|
-
"@stacksjs/env": "0.75.
|
|
71
|
-
"@stacksjs/error-handling": "0.75.
|
|
72
|
-
"@stacksjs/faker": "0.75.
|
|
73
|
-
"@stacksjs/features": "0.75.
|
|
74
|
-
"@stacksjs/logging": "0.75.
|
|
75
|
-
"@stacksjs/model-meta": "0.75.
|
|
76
|
-
"@stacksjs/path": "0.75.
|
|
77
|
-
"@stacksjs/query-builder": "0.75.
|
|
78
|
-
"@stacksjs/security": "0.75.
|
|
79
|
-
"@stacksjs/storage": "0.75.
|
|
80
|
-
"@stacksjs/strings": "0.75.
|
|
69
|
+
"@stacksjs/config": "0.75.178",
|
|
70
|
+
"@stacksjs/env": "0.75.178",
|
|
71
|
+
"@stacksjs/error-handling": "0.75.178",
|
|
72
|
+
"@stacksjs/faker": "0.75.178",
|
|
73
|
+
"@stacksjs/features": "0.75.178",
|
|
74
|
+
"@stacksjs/logging": "0.75.178",
|
|
75
|
+
"@stacksjs/model-meta": "0.75.178",
|
|
76
|
+
"@stacksjs/path": "0.75.178",
|
|
77
|
+
"@stacksjs/query-builder": "0.75.178",
|
|
78
|
+
"@stacksjs/security": "0.75.178",
|
|
79
|
+
"@stacksjs/storage": "0.75.178",
|
|
80
|
+
"@stacksjs/strings": "0.75.178",
|
|
81
81
|
"@stacksjs/ts-validation": "^0.5.6",
|
|
82
82
|
"bun-query-builder": "^0.3.9",
|
|
83
83
|
"dynamodb-tooling": "^0.3.6"
|
|
84
84
|
},
|
|
85
85
|
"devDependencies": {
|
|
86
|
-
"@stacksjs/cli": "0.75.
|
|
87
|
-
"@stacksjs/router": "0.75.
|
|
88
|
-
"@stacksjs/utils": "0.75.
|
|
86
|
+
"@stacksjs/cli": "0.75.178",
|
|
87
|
+
"@stacksjs/router": "0.75.178",
|
|
88
|
+
"@stacksjs/utils": "0.75.178",
|
|
89
89
|
"better-dx": "^0.2.24"
|
|
90
90
|
}
|
|
91
91
|
}
|