velocious 1.0.455 → 1.0.457
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/README.md +1 -1
- package/build/cli/commands/db/drop.js +7 -3
- package/build/database/datetime-storage.js +188 -0
- package/build/database/drivers/base.js +2 -2
- package/build/database/drivers/mssql/index.js +4 -0
- package/build/database/drivers/mysql/index.js +11 -1
- package/build/database/drivers/pgsql/index.js +15 -3
- package/build/database/migration/index.js +127 -0
- package/build/database/record/index.js +6 -63
- package/build/environment-handlers/node/cli/commands/generate/frontend-models.js +19 -3
- package/build/frontend-model-controller.js +4 -2
- package/build/frontend-models/base.js +7 -0
- package/build/frontend-models/websocket-publishers.js +11 -0
- package/build/src/cli/commands/db/drop.d.ts.map +1 -1
- package/build/src/cli/commands/db/drop.js +10 -4
- package/build/src/database/datetime-storage.d.ts +56 -0
- package/build/src/database/datetime-storage.d.ts.map +1 -0
- package/build/src/database/datetime-storage.js +174 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +3 -3
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +4 -1
- package/build/src/database/drivers/mysql/index.d.ts +5 -0
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +10 -2
- package/build/src/database/drivers/pgsql/index.d.ts +5 -0
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/index.js +13 -3
- package/build/src/database/migration/index.d.ts +66 -0
- package/build/src/database/migration/index.d.ts.map +1 -1
- package/build/src/database/migration/index.js +112 -1
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +7 -52
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.d.ts.map +1 -1
- package/build/src/environment-handlers/node/cli/commands/generate/frontend-models.js +20 -4
- package/build/src/frontend-model-controller.d.ts.map +1 -1
- package/build/src/frontend-model-controller.js +5 -3
- package/build/src/frontend-models/base.d.ts.map +1 -1
- package/build/src/frontend-models/base.js +6 -1
- package/build/src/frontend-models/websocket-publishers.d.ts.map +1 -1
- package/build/src/frontend-models/websocket-publishers.js +12 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/cli/commands/db/drop.js +7 -3
- package/src/database/datetime-storage.js +188 -0
- package/src/database/drivers/base.js +2 -2
- package/src/database/drivers/mssql/index.js +4 -0
- package/src/database/drivers/mysql/index.js +11 -1
- package/src/database/drivers/pgsql/index.js +15 -3
- package/src/database/migration/index.js +127 -0
- package/src/database/record/index.js +6 -63
- package/src/environment-handlers/node/cli/commands/generate/frontend-models.js +19 -3
- package/src/frontend-model-controller.js +4 -2
- package/src/frontend-models/base.js +7 -0
- package/src/frontend-models/websocket-publishers.js +11 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Database models with migrations and validations
|
|
6
6
|
* Database models that work almost the same in frontend and backend
|
|
7
7
|
* Declarative state machines for models (see [docs/state-machine.md](docs/state-machine.md))
|
|
8
|
-
* Migrations for schema changes (see [docs/database-migrations.md](docs/database-migrations.md))
|
|
8
|
+
* Migrations for schema changes and UTC datetime storage (see [docs/database-migrations.md](docs/database-migrations.md))
|
|
9
9
|
* Controllers and views for HTTP endpoints
|
|
10
10
|
* Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, with structured per-attribute validation error responses (see [docs/frontend-models.md](docs/frontend-models.md))
|
|
11
11
|
* Expo / Metro compatibility guidance and a real Expo export check (see [docs/expo-metro-compatibility.md](docs/expo-metro-compatibility.md))
|
|
@@ -32,9 +32,13 @@ export default class DbDrop extends DbBaseCommand {
|
|
|
32
32
|
const configuredFallback = newConfiguration.useDatabase
|
|
33
33
|
const useConfiguredFallback = typeof configuredFallback == "string" && configuredFallback.length > 0 && configuredFallback != targetDatabaseName
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
if (useConfiguredFallback) {
|
|
36
|
+
newConfiguration.database = configuredFallback
|
|
37
|
+
} else if (databaseType == "mysql") {
|
|
38
|
+
delete newConfiguration.database
|
|
39
|
+
} else {
|
|
40
|
+
newConfiguration.database = this.systemFallbackDatabaseName(databaseType)
|
|
41
|
+
}
|
|
38
42
|
|
|
39
43
|
if (databaseType == "mssql" && newConfiguration.sqlConfig?.database) {
|
|
40
44
|
delete newConfiguration.sqlConfig.database
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import isDate from "../utils/is-date.js"
|
|
4
|
+
|
|
5
|
+
const dateTimeWithTimezonePattern = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:[zZ]|[+-]\d{2}:\d{2})$/
|
|
6
|
+
const dateTimeWithoutTimezonePattern = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?$/
|
|
7
|
+
const timezoneSuffixPattern = /(?:[zZ]|[+-]\d{2}:\d{2})$/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pads a numeric date/time part.
|
|
11
|
+
* @param {number} value - Numeric part.
|
|
12
|
+
* @param {number} length - Target length.
|
|
13
|
+
* @returns {string} - Padded part.
|
|
14
|
+
*/
|
|
15
|
+
function pad(value, length = 2) {
|
|
16
|
+
return String(value).padStart(length, "0")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Replaces SQL-style datetime separators with ISO separators for parsing.
|
|
21
|
+
* @param {string} value - Datetime string.
|
|
22
|
+
* @returns {string} - Datetime string with a `T` separator.
|
|
23
|
+
*/
|
|
24
|
+
function normalizeDateTimeSeparator(value) {
|
|
25
|
+
return value.includes("T") ? value : value.replace(" ", "T")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parses a datetime string with an explicit timezone.
|
|
30
|
+
* @param {string} value - Datetime string.
|
|
31
|
+
* @returns {Date | string} - Parsed date or the original string when it is not a recognized datetime.
|
|
32
|
+
*/
|
|
33
|
+
function parseTimezoneQualifiedDateTimeString(value) {
|
|
34
|
+
if (!dateTimeWithTimezonePattern.test(value)) return value
|
|
35
|
+
|
|
36
|
+
const timestamp = Date.parse(normalizeDateTimeSeparator(value))
|
|
37
|
+
|
|
38
|
+
if (Number.isNaN(timestamp)) return value
|
|
39
|
+
|
|
40
|
+
return new Date(timestamp)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parses a timezone-less datetime string as UTC.
|
|
45
|
+
* @param {string} value - Datetime string.
|
|
46
|
+
* @returns {Date | string} - Parsed date or the original string when it is not a recognized datetime.
|
|
47
|
+
*/
|
|
48
|
+
function parseTimezoneLessDateTimeStringAsUtc(value) {
|
|
49
|
+
if (!dateTimeWithoutTimezonePattern.test(value)) return value
|
|
50
|
+
|
|
51
|
+
const timestamp = Date.parse(`${normalizeDateTimeSeparator(value)}Z`)
|
|
52
|
+
|
|
53
|
+
if (Number.isNaN(timestamp)) return value
|
|
54
|
+
|
|
55
|
+
return new Date(timestamp)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parses a timezone-less datetime string as the current runtime's local wall-clock time.
|
|
60
|
+
* @param {string} value - Datetime string.
|
|
61
|
+
* @returns {Date | string} - Parsed date or the original string when it is not a recognized datetime.
|
|
62
|
+
*/
|
|
63
|
+
function parseTimezoneLessDateTimeStringAsLocal(value) {
|
|
64
|
+
if (!dateTimeWithoutTimezonePattern.test(value)) return value
|
|
65
|
+
|
|
66
|
+
const timestamp = Date.parse(normalizeDateTimeSeparator(value))
|
|
67
|
+
|
|
68
|
+
if (Number.isNaN(timestamp)) return value
|
|
69
|
+
|
|
70
|
+
return new Date(timestamp)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parses a timezone-less legacy datetime string with an explicit local offset.
|
|
75
|
+
* The offset follows JavaScript's `Date#getTimezoneOffset()` sign convention.
|
|
76
|
+
* @param {string} value - Datetime string.
|
|
77
|
+
* @param {number} legacyLocalOffsetMinutes - UTC-minus-local offset in minutes.
|
|
78
|
+
* @returns {Date | string} - Parsed date or the original string when it is not a recognized datetime.
|
|
79
|
+
*/
|
|
80
|
+
function parseTimezoneLessDateTimeStringWithOffset(value, legacyLocalOffsetMinutes) {
|
|
81
|
+
const utcDate = parseTimezoneLessDateTimeStringAsUtc(value)
|
|
82
|
+
|
|
83
|
+
if (!isDate(utcDate)) return value
|
|
84
|
+
|
|
85
|
+
return new Date(utcDate.getTime() + (legacyLocalOffsetMinutes * 60 * 1000))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Checks whether a string has a datetime timezone suffix.
|
|
90
|
+
* @param {string} value - Value to check.
|
|
91
|
+
* @returns {boolean} - Whether the string ends with `Z` or an offset.
|
|
92
|
+
*/
|
|
93
|
+
export function hasDateTimeTimezone(value) {
|
|
94
|
+
return timezoneSuffixPattern.test(value)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Formats a Date for database storage as a UTC instant.
|
|
99
|
+
* @param {Date} value - Date value.
|
|
100
|
+
* @param {object} args - Options.
|
|
101
|
+
* @param {string} args.databaseType - Database driver type.
|
|
102
|
+
* @returns {string} - Database datetime string.
|
|
103
|
+
*/
|
|
104
|
+
export function formatDateForDatabase(value, {databaseType}) {
|
|
105
|
+
if (databaseType == "sqlite") return value.toISOString()
|
|
106
|
+
|
|
107
|
+
return [
|
|
108
|
+
value.getUTCFullYear(),
|
|
109
|
+
"-",
|
|
110
|
+
pad(value.getUTCMonth() + 1),
|
|
111
|
+
"-",
|
|
112
|
+
pad(value.getUTCDate()),
|
|
113
|
+
" ",
|
|
114
|
+
pad(value.getUTCHours()),
|
|
115
|
+
":",
|
|
116
|
+
pad(value.getUTCMinutes()),
|
|
117
|
+
":",
|
|
118
|
+
pad(value.getUTCSeconds()),
|
|
119
|
+
".",
|
|
120
|
+
pad(value.getUTCMilliseconds(), 3)
|
|
121
|
+
].join("")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Normalizes a record write string into a Date when it is a recognized datetime string.
|
|
126
|
+
* Timezone-less strings are external input and are treated as UTC.
|
|
127
|
+
* @param {string} value - Value to normalize.
|
|
128
|
+
* @returns {Date | string} - Normalized value.
|
|
129
|
+
*/
|
|
130
|
+
export function normalizeDateStringForWrite(value) {
|
|
131
|
+
if (hasDateTimeTimezone(value)) return parseTimezoneQualifiedDateTimeString(value)
|
|
132
|
+
|
|
133
|
+
return parseTimezoneLessDateTimeStringAsUtc(value)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalizes a record write value into a Date when it is a recognized datetime string.
|
|
138
|
+
* Timezone-less strings are external input and are treated as UTC.
|
|
139
|
+
* @param {Date | string | null | undefined} value - Value to normalize.
|
|
140
|
+
* @returns {Date | string | null | undefined} - Normalized value.
|
|
141
|
+
*/
|
|
142
|
+
export function normalizeDateValueForWrite(value) {
|
|
143
|
+
if (typeof value != "string") return value
|
|
144
|
+
|
|
145
|
+
return normalizeDateStringForWrite(value)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Normalizes a database value into a Date for record reads.
|
|
150
|
+
* SQLite timezone-less rows are legacy local wall-clock rows produced before
|
|
151
|
+
* UTC storage. New SQLite writes include `Z`, so they take the exact branch.
|
|
152
|
+
* @param {Date | string | null | undefined} value - Stored database value.
|
|
153
|
+
* @param {object} args - Options.
|
|
154
|
+
* @param {string} args.databaseType - Database driver type.
|
|
155
|
+
* @returns {Date | string | null | undefined} - Normalized value.
|
|
156
|
+
*/
|
|
157
|
+
export function normalizeDateValueForRead(value, {databaseType}) {
|
|
158
|
+
if (value === null || value === undefined) return value
|
|
159
|
+
|
|
160
|
+
if (isDate(value)) return new Date(value.getTime())
|
|
161
|
+
if (typeof value != "string") return value
|
|
162
|
+
if (hasDateTimeTimezone(value)) return parseTimezoneQualifiedDateTimeString(value)
|
|
163
|
+
if (databaseType == "sqlite") return parseTimezoneLessDateTimeStringAsLocal(value)
|
|
164
|
+
|
|
165
|
+
return parseTimezoneLessDateTimeStringAsUtc(value)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Converts a legacy timezone-less datetime value into the new UTC database storage format.
|
|
170
|
+
* The optional offset follows JavaScript's `Date#getTimezoneOffset()` sign convention.
|
|
171
|
+
* @param {Date | string | null | undefined} value - Legacy value.
|
|
172
|
+
* @param {object} args - Options.
|
|
173
|
+
* @param {string} args.databaseType - Database driver type.
|
|
174
|
+
* @param {number | undefined} [args.legacyLocalOffsetMinutes] - UTC-minus-local offset in minutes.
|
|
175
|
+
* @returns {Date | string | null | undefined} - Converted database value or the original value.
|
|
176
|
+
*/
|
|
177
|
+
export function convertLegacyDateValueToUtcStorage(value, {databaseType, legacyLocalOffsetMinutes}) {
|
|
178
|
+
if (typeof value != "string") return value
|
|
179
|
+
if (hasDateTimeTimezone(value)) return value
|
|
180
|
+
|
|
181
|
+
const parsedDate = legacyLocalOffsetMinutes === undefined
|
|
182
|
+
? parseTimezoneLessDateTimeStringAsLocal(value)
|
|
183
|
+
: parseTimezoneLessDateTimeStringWithOffset(value, legacyLocalOffsetMinutes)
|
|
184
|
+
|
|
185
|
+
if (!isDate(parsedDate)) return value
|
|
186
|
+
|
|
187
|
+
return formatDateForDatabase(parsedDate, {databaseType})
|
|
188
|
+
}
|
|
@@ -103,12 +103,12 @@
|
|
|
103
103
|
|
|
104
104
|
import BacktraceCleaner from "../../utils/backtrace-cleaner.js"
|
|
105
105
|
import { getDatabaseAnnotations } from "../annotations.js"
|
|
106
|
+
import { formatDateForDatabase } from "../datetime-storage.js"
|
|
106
107
|
import isDate from "../../utils/is-date.js"
|
|
107
108
|
import Logger from "../../logger.js"
|
|
108
109
|
import Query from "../query/index.js"
|
|
109
110
|
import Handler from "../handler.js"
|
|
110
111
|
import Mutex from "epic-locks/build/mutex.js"
|
|
111
|
-
import strftime from "strftime"
|
|
112
112
|
import UUID from "pure-uuid"
|
|
113
113
|
import TableData from "../table-data/index.js"
|
|
114
114
|
import TableColumn from "../table-data/table-column.js"
|
|
@@ -656,7 +656,7 @@ export default class VelociousDatabaseDriversBase {
|
|
|
656
656
|
// isDate instead of instanceof: a Date created in another realm (e.g. the console REPL) would
|
|
657
657
|
// fail instanceof, skip this conversion, and serialize as an empty SQL value downstream.
|
|
658
658
|
if (isDate(value)) {
|
|
659
|
-
return
|
|
659
|
+
return formatDateForDatabase(value, {databaseType: this.getType()})
|
|
660
660
|
}
|
|
661
661
|
|
|
662
662
|
// JSON-encode plain objects/arrays so they land in JSON/text columns as valid
|
|
@@ -30,6 +30,10 @@ export default class VelociousDatabaseDriversMssql extends Base{
|
|
|
30
30
|
try {
|
|
31
31
|
if (this.connection) await this.close()
|
|
32
32
|
|
|
33
|
+
if (sqlConfig) {
|
|
34
|
+
sqlConfig.options = Object.assign({}, sqlConfig.options, {useUTC: true})
|
|
35
|
+
}
|
|
36
|
+
|
|
33
37
|
if (sqlConfig?.server && !sqlConfig.options?.serverName && net.isIP(sqlConfig.server)) {
|
|
34
38
|
sqlConfig.options = Object.assign({}, sqlConfig.options, {serverName: ""})
|
|
35
39
|
}
|
|
@@ -37,6 +37,8 @@ export default class VelociousDatabaseDriversMysql extends Base{
|
|
|
37
37
|
async connect() {
|
|
38
38
|
this.pool = mysql.createPool(Object.assign({connectionLimit: 1}, this.connectArgs()))
|
|
39
39
|
this.pool.on("error", this.onPoolError)
|
|
40
|
+
|
|
41
|
+
await this.setSessionTimezoneToUtc()
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/**
|
|
@@ -75,6 +77,14 @@ export default class VelociousDatabaseDriversMysql extends Base{
|
|
|
75
77
|
await super.clearConnectionCheckoutName()
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Sets the database session timezone to UTC so bare timestamp literals store UTC instants.
|
|
82
|
+
* @returns {Promise<void>} - Resolves when complete.
|
|
83
|
+
*/
|
|
84
|
+
async setSessionTimezoneToUtc() {
|
|
85
|
+
await this.query("SET time_zone = '+00:00'", {logName: "Set Session Time Zone", processListComment: false})
|
|
86
|
+
}
|
|
87
|
+
|
|
78
88
|
/**
|
|
79
89
|
* Runs connect args.
|
|
80
90
|
* @returns {Record<string, ?>} - The connect args.
|
|
@@ -86,7 +96,7 @@ export default class VelociousDatabaseDriversMysql extends Base{
|
|
|
86
96
|
/**
|
|
87
97
|
* Connect args.
|
|
88
98
|
* @type {Record<string, ?>} */
|
|
89
|
-
const connectArgs = {charset: "utf8mb4"}
|
|
99
|
+
const connectArgs = {charset: "utf8mb4", timezone: "Z"}
|
|
90
100
|
|
|
91
101
|
for (const forwardValue of forward) {
|
|
92
102
|
if (forwardValue in args) connectArgs[forwardValue] = digg(args, forwardValue)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import AlterTable from "./sql/alter-table.js"
|
|
4
4
|
import wait from "awaitery/build/wait.js"
|
|
5
5
|
import Base from "../base.js"
|
|
6
|
-
import {Client} from "pg"
|
|
6
|
+
import { Client, types as pgTypes } from "pg"
|
|
7
7
|
import CreateDatabase from "./sql/create-database.js"
|
|
8
8
|
import CreateIndex from "./sql/create-index.js"
|
|
9
9
|
import CreateTable from "./sql/create-table.js"
|
|
@@ -19,12 +19,18 @@ import StructureSql from "./structure-sql.js"
|
|
|
19
19
|
import Upsert from "./sql/upsert.js"
|
|
20
20
|
import Update from "./sql/update.js"
|
|
21
21
|
|
|
22
|
+
const PG_TIMESTAMP_WITHOUT_TIMEZONE_OID = 1114
|
|
23
|
+
|
|
24
|
+
pgTypes.setTypeParser(PG_TIMESTAMP_WITHOUT_TIMEZONE_OID, (value) => new Date(`${value.replace(" ", "T")}Z`))
|
|
25
|
+
|
|
22
26
|
export default class VelociousDatabaseDriversPgsql extends Base{
|
|
23
27
|
async connect() {
|
|
24
28
|
const client = new Client(this.connectArgs())
|
|
25
29
|
|
|
26
30
|
try {
|
|
27
31
|
await client.connect()
|
|
32
|
+
this.connection = client
|
|
33
|
+
await this.setSessionTimezoneToUtc()
|
|
28
34
|
} catch (error) {
|
|
29
35
|
// Re-throw to recover real stack trace
|
|
30
36
|
if (error instanceof Error) {
|
|
@@ -33,8 +39,6 @@ export default class VelociousDatabaseDriversPgsql extends Base{
|
|
|
33
39
|
throw new Error(`Connect to Postgres server failed: ${error}`, {cause: error})
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
|
-
|
|
37
|
-
this.connection = client
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
connectArgs() {
|
|
@@ -85,6 +89,14 @@ export default class VelociousDatabaseDriversPgsql extends Base{
|
|
|
85
89
|
await super.clearConnectionCheckoutName()
|
|
86
90
|
}
|
|
87
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Sets the database session timezone to UTC so bare timestamp literals store UTC instants.
|
|
94
|
+
* @returns {Promise<void>} - Resolves when complete.
|
|
95
|
+
*/
|
|
96
|
+
async setSessionTimezoneToUtc() {
|
|
97
|
+
await this.query("SET TIME ZONE 'UTC'", {logName: "Set Session Time Zone", processListComment: false})
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
/**
|
|
89
101
|
* Runs alter table sqls.
|
|
90
102
|
* @param {import("../../table-data/index.js").default} tableData - Table data.
|
|
@@ -26,7 +26,15 @@
|
|
|
26
26
|
* CreateTableCallbackType type.
|
|
27
27
|
* @typedef {(table: TableData) => void} CreateTableCallbackType
|
|
28
28
|
*/
|
|
29
|
+
/**
|
|
30
|
+
* LegacyLocalDateTimesMigrationArgsType type.
|
|
31
|
+
* @typedef {object} LegacyLocalDateTimesMigrationArgsType
|
|
32
|
+
* @property {Record<string, string[]>} [columnsByTable] - Explicit datetime columns keyed by table name.
|
|
33
|
+
* @property {number} [legacyLocalOffsetMinutes] - UTC-minus-local offset in minutes for legacy rows.
|
|
34
|
+
* @property {string[]} [tables] - Tables to migrate. Defaults to all non-internal tables.
|
|
35
|
+
*/
|
|
29
36
|
|
|
37
|
+
import { convertLegacyDateValueToUtcStorage } from "../datetime-storage.js"
|
|
30
38
|
import * as inflection from "inflection"
|
|
31
39
|
import restArgsError from "../../utils/rest-args-error.js"
|
|
32
40
|
import TableData from "../table-data/index.js"
|
|
@@ -275,6 +283,125 @@ export default class VelociousDatabaseMigration {
|
|
|
275
283
|
await column.changeNullable(nullable)
|
|
276
284
|
}
|
|
277
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Migrates legacy timezone-less local datetime rows into UTC datetime storage.
|
|
288
|
+
* New SQLite UTC rows include a timezone suffix and are skipped.
|
|
289
|
+
* @param {LegacyLocalDateTimesMigrationArgsType} [args] - Migration options.
|
|
290
|
+
* @returns {Promise<void>} - Resolves when complete.
|
|
291
|
+
*/
|
|
292
|
+
async migrateLegacyLocalDateTimesToUtcStorage(args = {}) {
|
|
293
|
+
const {columnsByTable, legacyLocalOffsetMinutes, tables, ...restArgs} = args
|
|
294
|
+
|
|
295
|
+
restArgsError(restArgs)
|
|
296
|
+
|
|
297
|
+
const tableNames = await this._legacyLocalDateTimesTableNames(tables)
|
|
298
|
+
|
|
299
|
+
for (const tableName of tableNames) {
|
|
300
|
+
await this._migrateLegacyLocalDateTimesTable({
|
|
301
|
+
columnsByTable,
|
|
302
|
+
legacyLocalOffsetMinutes,
|
|
303
|
+
tableName
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Resolves table names for a legacy local datetime migration.
|
|
310
|
+
* @param {string[] | undefined} tables - Explicit table names.
|
|
311
|
+
* @returns {Promise<string[]>} - Table names.
|
|
312
|
+
*/
|
|
313
|
+
async _legacyLocalDateTimesTableNames(tables) {
|
|
314
|
+
if (tables) return tables
|
|
315
|
+
|
|
316
|
+
return (await this.getDriver().getTables())
|
|
317
|
+
.map((table) => table.getName())
|
|
318
|
+
.filter((tableName) => tableName != "schema_migrations" && !tableName.startsWith("sqlite_"))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Migrates one table's legacy local datetime values.
|
|
323
|
+
* @param {object} args - Options.
|
|
324
|
+
* @param {Record<string, string[]> | undefined} args.columnsByTable - Explicit columns keyed by table.
|
|
325
|
+
* @param {number | undefined} args.legacyLocalOffsetMinutes - UTC-minus-local offset in minutes.
|
|
326
|
+
* @param {string} args.tableName - Table name.
|
|
327
|
+
* @returns {Promise<void>} - Resolves when complete.
|
|
328
|
+
*/
|
|
329
|
+
async _migrateLegacyLocalDateTimesTable({columnsByTable, legacyLocalOffsetMinutes, tableName}) {
|
|
330
|
+
const driver = this.getDriver()
|
|
331
|
+
const table = await driver.getTableByNameOrFail(tableName)
|
|
332
|
+
const columns = await this._legacyLocalDateTimesColumns({columnsByTable, table})
|
|
333
|
+
|
|
334
|
+
if (columns.length === 0) return
|
|
335
|
+
|
|
336
|
+
const primaryKeyColumn = await this._legacyLocalDateTimesPrimaryKey(table)
|
|
337
|
+
const selectedColumns = [primaryKeyColumn, ...columns]
|
|
338
|
+
const selectSql = selectedColumns
|
|
339
|
+
.map((columnName) => driver.quoteColumn(columnName))
|
|
340
|
+
.join(", ")
|
|
341
|
+
const rows = await driver.query(`SELECT ${selectSql} FROM ${driver.quoteTable(tableName)}`)
|
|
342
|
+
|
|
343
|
+
for (const row of rows) {
|
|
344
|
+
for (const columnName of columns) {
|
|
345
|
+
const value = row[columnName]
|
|
346
|
+
const convertedValue = convertLegacyDateValueToUtcStorage(value, {
|
|
347
|
+
databaseType: driver.getType(),
|
|
348
|
+
legacyLocalOffsetMinutes
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
if (convertedValue === value) continue
|
|
352
|
+
|
|
353
|
+
await driver.query(`
|
|
354
|
+
UPDATE ${driver.quoteTable(tableName)}
|
|
355
|
+
SET ${driver.quoteColumn(columnName)} = ${driver.quote(convertedValue)}
|
|
356
|
+
WHERE ${driver.quoteColumn(primaryKeyColumn)} = ${driver.quote(row[primaryKeyColumn])}
|
|
357
|
+
`)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Resolves date-like columns for one table.
|
|
364
|
+
* @param {object} args - Options.
|
|
365
|
+
* @param {Record<string, string[]> | undefined} args.columnsByTable - Explicit columns keyed by table.
|
|
366
|
+
* @param {import("../drivers/base-table.js").default} args.table - Table metadata.
|
|
367
|
+
* @returns {Promise<string[]>} - Date-like column names.
|
|
368
|
+
*/
|
|
369
|
+
async _legacyLocalDateTimesColumns({columnsByTable, table}) {
|
|
370
|
+
const explicitColumns = columnsByTable?.[table.getName()]
|
|
371
|
+
|
|
372
|
+
if (explicitColumns) return explicitColumns
|
|
373
|
+
|
|
374
|
+
return (await table.getColumns())
|
|
375
|
+
.filter((column) => this._legacyLocalDateTimesColumnIsDateLike(column))
|
|
376
|
+
.map((column) => column.getName())
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Checks whether a column should be included by default.
|
|
381
|
+
* @param {import("../drivers/base-column.js").default} column - Column metadata.
|
|
382
|
+
* @returns {boolean} - Whether the column is date-like.
|
|
383
|
+
*/
|
|
384
|
+
_legacyLocalDateTimesColumnIsDateLike(column) {
|
|
385
|
+
const columnType = column.getType().toLowerCase()
|
|
386
|
+
|
|
387
|
+
return columnType.includes("date") || columnType.includes("timestamp")
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Resolves the single primary key column for row updates.
|
|
392
|
+
* @param {import("../drivers/base-table.js").default} table - Table metadata.
|
|
393
|
+
* @returns {Promise<string>} - Primary key column name.
|
|
394
|
+
*/
|
|
395
|
+
async _legacyLocalDateTimesPrimaryKey(table) {
|
|
396
|
+
const primaryKeyColumns = (await table.getColumns()).filter((column) => column.getPrimaryKey())
|
|
397
|
+
|
|
398
|
+
if (primaryKeyColumns.length != 1) {
|
|
399
|
+
throw new Error(`Expected exactly one primary key on ${table.getName()} but found ${primaryKeyColumns.length}`)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return primaryKeyColumns[0].getName()
|
|
403
|
+
}
|
|
404
|
+
|
|
278
405
|
/**
|
|
279
406
|
* Runs column exists.
|
|
280
407
|
* @param {string} tableName - Table name.
|
|
@@ -31,13 +31,13 @@ import HasOneRelationship from "./relationships/has-one.js"
|
|
|
31
31
|
import RecordAttachmentHandle from "./attachments/handle.js"
|
|
32
32
|
import * as inflection from "inflection"
|
|
33
33
|
import deburrColumnName from "../../utils/deburr-column-name.js"
|
|
34
|
-
import isDate from "../../utils/is-date.js"
|
|
35
34
|
import ModelClassQuery from "../query/model-class-query.js"
|
|
36
35
|
import Preloader from "../query/preloader.js"
|
|
37
36
|
import {readPayloadAssociationCount, readPayloadComputedAbility, readPayloadQueryData, setPayloadAssociationCount, setPayloadComputedAbility, setPayloadQueryData} from "../../record-payload-values.js"
|
|
38
37
|
import restArgsError from "../../utils/rest-args-error.js"
|
|
39
38
|
import singularizeModelName from "../../utils/singularize-model-name.js"
|
|
40
39
|
import {defineModelScope} from "../../utils/model-scope.js"
|
|
40
|
+
import { normalizeDateStringForWrite, normalizeDateValueForRead, normalizeDateValueForWrite } from "../datetime-storage.js"
|
|
41
41
|
import {formatValue} from "../../utils/format-value.js"
|
|
42
42
|
import ValidatorsFormat from "./validators/format.js"
|
|
43
43
|
import ValidatorsPresence from "./validators/presence.js"
|
|
@@ -1866,17 +1866,7 @@ class VelociousDatabaseRecord {
|
|
|
1866
1866
|
* @returns {?} - The date value.
|
|
1867
1867
|
*/
|
|
1868
1868
|
_normalizeDateValue(value) {
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
const isoDateTimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/
|
|
1872
|
-
|
|
1873
|
-
if (!isoDateTimeRegex.test(value)) return value
|
|
1874
|
-
|
|
1875
|
-
const timestamp = Date.parse(value)
|
|
1876
|
-
|
|
1877
|
-
if (Number.isNaN(timestamp)) return value
|
|
1878
|
-
|
|
1879
|
-
return new Date(timestamp)
|
|
1869
|
+
return normalizeDateValueForWrite(value)
|
|
1880
1870
|
}
|
|
1881
1871
|
|
|
1882
1872
|
/**
|
|
@@ -2240,21 +2230,7 @@ class VelociousDatabaseRecord {
|
|
|
2240
2230
|
* @returns {?} - Normalized value.
|
|
2241
2231
|
*/
|
|
2242
2232
|
static _normalizeDateValueForInsert(value) {
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
if (typeof normalizedValue == "string") {
|
|
2246
|
-
normalizedValue = this._normalizeDateStringForInsert(normalizedValue)
|
|
2247
|
-
}
|
|
2248
|
-
|
|
2249
|
-
if (isDate(normalizedValue)) {
|
|
2250
|
-
const configuration = this._getConfiguration()
|
|
2251
|
-
const offsetMinutes = configuration.getEnvironmentHandler().getTimezoneOffsetMinutes(configuration)
|
|
2252
|
-
const offsetMs = offsetMinutes * 60 * 1000
|
|
2253
|
-
|
|
2254
|
-
normalizedValue = new Date(normalizedValue.getTime() - offsetMs)
|
|
2255
|
-
}
|
|
2256
|
-
|
|
2257
|
-
return normalizedValue
|
|
2233
|
+
return normalizeDateValueForWrite(value)
|
|
2258
2234
|
}
|
|
2259
2235
|
|
|
2260
2236
|
/**
|
|
@@ -2263,15 +2239,7 @@ class VelociousDatabaseRecord {
|
|
|
2263
2239
|
* @returns {string | Date} - Parsed date or original string.
|
|
2264
2240
|
*/
|
|
2265
2241
|
static _normalizeDateStringForInsert(value) {
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
if (!isoDateTimeRegex.test(value)) return value
|
|
2269
|
-
|
|
2270
|
-
const timestamp = Date.parse(value)
|
|
2271
|
-
|
|
2272
|
-
if (Number.isNaN(timestamp)) return value
|
|
2273
|
-
|
|
2274
|
-
return new Date(timestamp)
|
|
2242
|
+
return normalizeDateStringForWrite(value)
|
|
2275
2243
|
}
|
|
2276
2244
|
|
|
2277
2245
|
/**
|
|
@@ -3925,26 +3893,7 @@ class VelociousDatabaseRecord {
|
|
|
3925
3893
|
* @returns {?} - Normalized value.
|
|
3926
3894
|
*/
|
|
3927
3895
|
_normalizeDateValueForRead(value) {
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
const configuration = this.getModelClass()._getConfiguration()
|
|
3931
|
-
const offsetMinutes = configuration.getEnvironmentHandler().getTimezoneOffsetMinutes(configuration)
|
|
3932
|
-
const offsetMs = offsetMinutes * 60 * 1000
|
|
3933
|
-
|
|
3934
|
-
if (isDate(value)) {
|
|
3935
|
-
return new Date(value.getTime() + offsetMs)
|
|
3936
|
-
}
|
|
3937
|
-
|
|
3938
|
-
if (typeof value != "string") return value
|
|
3939
|
-
|
|
3940
|
-
const hasTimezone = /[zZ]|[+-]\d{2}:\d{2}$/.test(value)
|
|
3941
|
-
const normalized = value.includes("T") ? value : value.replace(" ", "T")
|
|
3942
|
-
const parseValue = hasTimezone ? normalized : `${normalized}Z`
|
|
3943
|
-
const parsed = Date.parse(parseValue)
|
|
3944
|
-
|
|
3945
|
-
if (Number.isNaN(parsed)) return value
|
|
3946
|
-
|
|
3947
|
-
return new Date(parsed + offsetMs)
|
|
3896
|
+
return normalizeDateValueForRead(value, {databaseType: this.getModelClass().getDatabaseType()})
|
|
3948
3897
|
}
|
|
3949
3898
|
|
|
3950
3899
|
_belongsToChanges() {
|
|
@@ -4079,10 +4028,6 @@ class VelociousDatabaseRecord {
|
|
|
4079
4028
|
* @returns {void} - No return value.
|
|
4080
4029
|
*/
|
|
4081
4030
|
_normalizeDateValuesForWrite(data) {
|
|
4082
|
-
const configuration = this.getModelClass()._getConfiguration()
|
|
4083
|
-
const offsetMinutes = configuration.getEnvironmentHandler().getTimezoneOffsetMinutes(configuration)
|
|
4084
|
-
const offsetMs = offsetMinutes * 60 * 1000
|
|
4085
|
-
|
|
4086
4031
|
for (const columnName in data) {
|
|
4087
4032
|
const columnType = this.getModelClass().getColumnTypeByName(columnName)
|
|
4088
4033
|
|
|
@@ -4090,9 +4035,7 @@ class VelociousDatabaseRecord {
|
|
|
4090
4035
|
|
|
4091
4036
|
const value = data[columnName]
|
|
4092
4037
|
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
data[columnName] = new Date(value.getTime() - offsetMs)
|
|
4038
|
+
data[columnName] = normalizeDateValueForWrite(value)
|
|
4096
4039
|
}
|
|
4097
4040
|
}
|
|
4098
4041
|
|
|
@@ -106,7 +106,13 @@ export default class DbGenerateFrontendModels extends BaseCommand {
|
|
|
106
106
|
throw new Error(`Invalid frontend model resource definition for '${className}'`)
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
const
|
|
109
|
+
const resolvedResourceClass = frontendModelResourceClassFromDefinition(resources[modelClassName])
|
|
110
|
+
// An abstract base resource (no static ModelClass — e.g. an app's shared
|
|
111
|
+
// `BaseResource` that other resources extend) can't back a generated
|
|
112
|
+
// frontend model. Treat it as resource-less so the generator falls back
|
|
113
|
+
// to by-name model lookup + empty write params instead of throwing when
|
|
114
|
+
// it eagerly calls `modelClass()` / `permittedParams()` on it.
|
|
115
|
+
const resourceClass = resolvedResourceClass && resolvedResourceClass.ModelClass ? resolvedResourceClass : null
|
|
110
116
|
|
|
111
117
|
this.validateModelConfig({availableFrontendModelClassNames, className, modelConfig, resourceClass})
|
|
112
118
|
|
|
@@ -1516,7 +1522,12 @@ export default class DbGenerateFrontendModels extends BaseCommand {
|
|
|
1516
1522
|
const classBodyEnd = this.matchingBraceIndex({openIndex: classBodyStart - 1, sourceText})
|
|
1517
1523
|
|
|
1518
1524
|
if (classBodyEnd == null) {
|
|
1519
|
-
|
|
1525
|
+
// The brace matcher can't tokenize every construct (e.g. a regex literal
|
|
1526
|
+
// whose quotes look like string delimiters), so it can fail to locate a
|
|
1527
|
+
// class body. Skip metadata extraction for that class rather than
|
|
1528
|
+
// aborting the whole frontend-model generation; resources that parse
|
|
1529
|
+
// cleanly still get their JSDoc-derived return/param types.
|
|
1530
|
+
continue
|
|
1520
1531
|
}
|
|
1521
1532
|
|
|
1522
1533
|
const classBody = sourceText.slice(classBodyStart, classBodyEnd)
|
|
@@ -1557,7 +1568,12 @@ export default class DbGenerateFrontendModels extends BaseCommand {
|
|
|
1557
1568
|
const classBodyEnd = this.matchingBraceIndex({openIndex: classBodyStart - 1, sourceText})
|
|
1558
1569
|
|
|
1559
1570
|
if (classBodyEnd == null) {
|
|
1560
|
-
|
|
1571
|
+
// The brace matcher can't tokenize every construct (e.g. a regex literal
|
|
1572
|
+
// whose quotes look like string delimiters), so it can fail to locate a
|
|
1573
|
+
// class body. Skip metadata extraction for that class rather than
|
|
1574
|
+
// aborting the whole frontend-model generation; resources that parse
|
|
1575
|
+
// cleanly still get their JSDoc-derived return/param types.
|
|
1576
|
+
continue
|
|
1561
1577
|
}
|
|
1562
1578
|
|
|
1563
1579
|
const classBody = sourceText.slice(classBodyStart, classBodyEnd)
|
|
@@ -10,7 +10,9 @@ import {normalizeGroup as normalizeQueryGroup, normalizeJoins as normalizeQueryJ
|
|
|
10
10
|
import {assignSafeProperty, deserializeFrontendModelTransportValue, isBackendModelInstance, serializeFrontendModelTransportValue} from "./frontend-models/transport-serialization.js"
|
|
11
11
|
import RoutesResolver from "./routes/resolver.js"
|
|
12
12
|
import {ValidationError} from "./database/record/index.js"
|
|
13
|
+
import { normalizeDateStringForWrite } from "./database/datetime-storage.js"
|
|
13
14
|
import VelociousError from "./velocious-error.js"
|
|
15
|
+
import isDate from "./utils/is-date.js"
|
|
14
16
|
import isPlainObject from "./utils/plain-object.js"
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -2212,9 +2214,9 @@ export default class FrontendModelController extends Controller {
|
|
|
2212
2214
|
const isDateTimeColumn = typeof columnType === "string" && ["date", "datetime", "timestamp"].some((type) => columnType.includes(type))
|
|
2213
2215
|
|
|
2214
2216
|
if (isDateTimeColumn) {
|
|
2215
|
-
const parsedDate =
|
|
2217
|
+
const parsedDate = normalizeDateStringForWrite(value)
|
|
2216
2218
|
|
|
2217
|
-
if (
|
|
2219
|
+
if (isDate(parsedDate)) {
|
|
2218
2220
|
return parsedDate
|
|
2219
2221
|
}
|
|
2220
2222
|
}
|