@stacksjs/database 0.75.177 → 0.75.179

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.
@@ -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
+ }
@@ -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 async function ensureUtcDatetimeColumns(options={}){const driver=resolvedDatabaseDriver();if(dialectCapabilities(driver).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}}}
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.177",
5
+ "version": "0.75.179",
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.177",
70
- "@stacksjs/env": "0.75.177",
71
- "@stacksjs/error-handling": "0.75.177",
72
- "@stacksjs/faker": "0.75.177",
73
- "@stacksjs/features": "0.75.177",
74
- "@stacksjs/logging": "0.75.177",
75
- "@stacksjs/model-meta": "0.75.177",
76
- "@stacksjs/path": "0.75.177",
77
- "@stacksjs/query-builder": "0.75.177",
78
- "@stacksjs/security": "0.75.177",
79
- "@stacksjs/storage": "0.75.177",
80
- "@stacksjs/strings": "0.75.177",
69
+ "@stacksjs/config": "0.75.179",
70
+ "@stacksjs/env": "0.75.179",
71
+ "@stacksjs/error-handling": "0.75.179",
72
+ "@stacksjs/faker": "0.75.179",
73
+ "@stacksjs/features": "0.75.179",
74
+ "@stacksjs/logging": "0.75.179",
75
+ "@stacksjs/model-meta": "0.75.179",
76
+ "@stacksjs/path": "0.75.179",
77
+ "@stacksjs/query-builder": "0.75.179",
78
+ "@stacksjs/security": "0.75.179",
79
+ "@stacksjs/storage": "0.75.179",
80
+ "@stacksjs/strings": "0.75.179",
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.177",
87
- "@stacksjs/router": "0.75.177",
88
- "@stacksjs/utils": "0.75.177",
86
+ "@stacksjs/cli": "0.75.179",
87
+ "@stacksjs/router": "0.75.179",
88
+ "@stacksjs/utils": "0.75.179",
89
89
  "better-dx": "^0.2.24"
90
90
  }
91
91
  }