velocious 1.0.456 → 1.0.458
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 +3 -1
- package/build/cli/commands/db/drop.js +7 -3
- package/build/configuration-types.js +18 -2
- 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 +76 -9
- package/build/frontend-model-controller.js +4 -2
- package/build/frontend-models/base.js +44 -15
- package/build/frontend-models/resource-definition.js +105 -14
- 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/configuration-types.d.ts +53 -6
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +17 -3
- 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 +35 -0
- 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 +68 -10
- 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 +73 -51
- package/build/src/frontend-models/base.d.ts.map +1 -1
- package/build/src/frontend-models/base.js +43 -16
- package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
- package/build/src/frontend-models/resource-definition.js +92 -15
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/cli/commands/db/drop.js +7 -3
- package/src/configuration-types.js +18 -2
- 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 +76 -9
- package/src/frontend-model-controller.js +4 -2
- package/src/frontend-models/base.js +44 -15
- package/src/frontend-models/resource-definition.js +105 -14
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))
|
|
@@ -378,6 +378,8 @@ export default new Configuration({
|
|
|
378
378
|
`frontendModels` entries must be `FrontendModelBaseResource` subclasses. Built-in CRUD/find/index/serialize behavior lives in the base class, and app resources override only the pieces they actually need.
|
|
379
379
|
Resource-level index customization should prefer `indexQuery()` or the pagination/search/sort hooks over replacing `records()`, so built-in pluck and aggregate count support can keep using the same query. See [`docs/frontend-model-resources.md`](docs/frontend-model-resources.md) for the resource extension points.
|
|
380
380
|
|
|
381
|
+
Custom class- and instance-level commands are declared via `collectionCommands` / `memberCommands`. Each entry is a plain camelCase method name, or a `{name, args?, returnType?}` object that types the command's arguments and response — e.g. `memberCommands: ["suspend", {name: "refresh", args: [{name: "age", type: "number"}], returnType: "string"}]`. See [`docs/frontend-model-resources.md#custom-commands`](docs/frontend-model-resources.md#custom-commands).
|
|
382
|
+
|
|
381
383
|
Resources expose the full CRUD ability set (`create`, `destroy`, `read`, `update`) by default. To restrict the API surface — for example to a read-only resource — declare an explicit subset:
|
|
382
384
|
|
|
383
385
|
```js
|
|
@@ -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
|
|
@@ -304,8 +304,8 @@
|
|
|
304
304
|
* @property {string[]} [abilities] - Ability action list (camelCase action names). Defaults to `["read"]` for `find` and `index` when omitted.
|
|
305
305
|
* @property {Record<string, FrontendModelAttachmentConfiguration>} [attachments] - Attachment helpers keyed by attachment name.
|
|
306
306
|
* @property {string[]} [commands] - Legacy built-in command names (`index`, `find`, `create`, `update`, `destroy`, `attach`, `download`, `url`).
|
|
307
|
-
* @property {
|
|
308
|
-
* @property {
|
|
307
|
+
* @property {Array<FrontendModelResourceCustomCommand>} [collectionCommands] - Custom collection commands. Each entry is a camelCase method name, or a `{name, args?, returnType?}` object declaring typed arguments and/or a response type. The runtime derives the kebab-case command slug from the name.
|
|
308
|
+
* @property {Array<FrontendModelResourceCustomCommand>} [memberCommands] - Custom member commands. Each entry is a camelCase method name, or a `{name, args?, returnType?}` object declaring typed arguments and/or a response type. The runtime derives the kebab-case command slug from the name.
|
|
309
309
|
* @property {string[]} [builtInCollectionCommands] - Built-in collection command names (`index`, `create`).
|
|
310
310
|
* @property {string[]} [builtInMemberCommands] - Built-in member command names (`find`, `update`, `destroy`, `attach`, `download`, `url`).
|
|
311
311
|
* @property {string[]} [relationships] - Relationship names to expose in frontend models. Type and target model are inferred from the backend model's registered relationships.
|
|
@@ -313,12 +313,28 @@
|
|
|
313
313
|
* @property {FrontendModelResourceServerConfiguration} [server] - Optional legacy backend behavior overrides for built-in frontend actions.
|
|
314
314
|
*/
|
|
315
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Object form of a custom command entry, declaring its typed arguments and/or
|
|
318
|
+
* response type alongside the command name.
|
|
319
|
+
* @typedef {object} FrontendModelResourceCustomCommandObject
|
|
320
|
+
* @property {string} name - camelCase command method name.
|
|
321
|
+
* @property {Array<{name: string, type: string}>} [args] - Typed command arguments; each generates a named, typed method parameter mapped positionally into the command payload. `type` is a JSDoc type string.
|
|
322
|
+
* @property {string} [returnType] - JSDoc type for the command response. When set, the generated method is typed `Promise<returnType>` instead of `Promise<Record<string, ?>>`. Emitted verbatim into the generated frontend model, so it must resolve there.
|
|
323
|
+
*/
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A custom command entry: a plain camelCase method name, or an object declaring
|
|
327
|
+
* typed args and/or a response type.
|
|
328
|
+
* @typedef {string | FrontendModelResourceCustomCommandObject} FrontendModelResourceCustomCommand
|
|
329
|
+
*/
|
|
330
|
+
|
|
316
331
|
/**
|
|
317
332
|
* @typedef {Omit<FrontendModelResourceConfiguration, "abilities" | "builtInCollectionCommands" | "builtInMemberCommands" | "collectionCommands" | "commands" | "memberCommands"> & {
|
|
318
333
|
* abilities: FrontendModelResourceAbilitiesConfiguration
|
|
319
334
|
* builtInCollectionCommands: Record<string, string>
|
|
320
335
|
* builtInMemberCommands: Record<string, string>
|
|
321
336
|
* collectionCommands: Record<string, string>
|
|
337
|
+
* commandMetadata: Record<string, {args: Array<{name: string, type: string}>, returnType: string | null}>
|
|
322
338
|
* memberCommands: Record<string, string>
|
|
323
339
|
* }} NormalizedFrontendModelResourceConfiguration
|
|
324
340
|
*/
|
|
@@ -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
|
|