@xeplr/actions 1.1.19 → 1.1.20

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 CHANGED
@@ -300,9 +300,20 @@ Logical types and what each driver creates:
300
300
  | number | NUMERIC | DECIMAL(38,10) | DECIMAL(38,10) | DOUBLE |
301
301
  | boolean | BOOLEAN | TINYINT(1) | BIT | BOOLEAN |
302
302
  | date | DATE | DATE | DATE | DATE |
303
- | datetime | TIMESTAMPTZ | DATETIME(6) | DATETIME2 | TIMESTAMPTZ |
303
+ | datetime (an instant) | TIMESTAMPTZ | DATETIME(6), in UTC | DATETIME2, in UTC | TIMESTAMPTZ |
304
+ | localdatetime (no zone) | TIMESTAMP | DATETIME(6) | DATETIME2 | TIMESTAMP |
304
305
  | object / array | JSONB | JSON | NVARCHAR(MAX) | JSON |
305
306
 
307
+ **Dates and times follow three rules, on every driver** (`lib/drivers/db/dateValues.js`, the one copy):
308
+
309
+ 1. **A `date` is copied as the same day.** MySQL/Postgres/SQL Server `DATE` → `date`.
310
+ 2. **A date-time with a zone is copied as the same instant**, stored in UTC. MySQL `TIMESTAMP`, Postgres `timestamptz`, SQL Server `datetimeoffset`, DuckDB `TIMESTAMPTZ` → `datetime`.
311
+ 3. **A date-time without a zone is copied as its digits**, `10:00` stays `10:00`, and stays zoneless. MySQL `DATETIME`, Postgres `timestamp`, SQL Server `datetime`/`datetime2`/`smalldatetime`, DuckDB `TIMESTAMP` → `localdatetime`. Which zone it was recorded in is declared where it is used (a cube's source zone), never guessed here.
312
+
313
+ **Nothing reads the machine's time zone.** The MySQL session is pinned to UTC (`SET time_zone = '+00:00'` on every connection), SQL Server to `useUTC: true` (after the caller's options), Postgres returns `date` and `timestamp` as text, and DuckDB opens with `TimeZone = 'UTC'`. Every JS `Date` is read with `getUTC*`, and a string with no zone is read as UTC, never handed to `new Date()`. `test/dates-across-zones.test.js` moves a real table from each of the three databases into DuckDB with the process in India time and in New York time, and the two must store the same values.
314
+
315
+ **An existing target column that is zoned but receives a zoneless source column is retyped once** to zoneless, keeping its digits (Postgres and DuckDB; `buildRetypeToLocalDateTimeSql`). Tables created before this rule held those values as instants.
316
+
306
317
  Inference (`inferColumns`): any array → `array`; any object → `object`; all `Date` or ISO-date strings → `datetime`; all booleans → `boolean`; all numbers → `number`; anything else, or all null → `string`.
307
318
 
308
319
  ### Staged loads
@@ -421,7 +432,7 @@ await driver.query(pool, userSql, params) // reads only
421
432
  | Movement logs carry identifiers, counts, SQL and the error text — never parameter values or row contents; a failed batch shows one row's primary key, not its data. | Logs are copied into tickets and chats. |
422
433
  | DuckDB opens **read-only unless the call says `access: 'rw'`**; no environment variable can grant write. A file already open read-only in the process refuses a write. Opening waits on the lock (`openTimeoutMs`, default 60 s). | One read-write process locks every other process out, even readers. Write access belongs at the call site that writes. |
423
434
  | **Every DuckDB value comes out plain**, from `query` and `fetchStream` alike: a number, text, boolean, `Date`, array or object, never a BigInt or a DuckDB wrapper object. DECIMAL is a number; DATE and TIMESTAMP are `Date`s; a whole number too big to hold exactly (beyond ±9,007,199,254,740,991) is **text**, never rounded; lists, structs and maps are arrays and objects; TIME, INTERVAL, UUID and any other type are text. `fetchStream` has DuckDB do the conversion inside its engine by wrapping the query (about +3% on a million rows); a query with nothing to convert runs as given. | A streamed read used to hand DECIMAL and TIMESTAMP back as wrapper objects with a BigInt inside, and the first `JSON.stringify` downstream failed with "Do not know how to serialize a BigInt". Text for huge numbers is what the Postgres, MySQL and SQL Server drivers already return. |
424
- | **A DuckDB timestamp without a zone is read as UTC**; one with a zone keeps its exact instant. | Data is kept in UTC; the viewer's time zone is applied when rendering, never in the driver. |
435
+ | **DuckDB works in UTC** (`TimeZone = 'UTC'` on every instance). A timestamp without a zone is read with its digits in the `Date`'s UTC fields; one with a zone keeps its exact instant. | Left to the machine's zone, `date_trunc('month', …)` of 1 Aug 00:00 UTC on a server in India was 31 Jul 18:30, so August was labelled July, and a filter "to 31 Aug" ended at 30 Aug 18:30. |
425
436
  | `meta-store-knex` requires `service` and `applicationId`, and scopes every statement by `applicationId`. | `import_meta` is shared by every app; unattributed rows, or a rollback decided from another app's row, are worse than refusing. |
426
437
  | Mail filenames are sanitised (`[^\w.-]` → `_`, all-dot names → `attachment`, max 180 chars) and prefixed with a random id. | Attachment names come from the sender and could escape the output directory; common names collide. |
427
438
  | The system mail sender refuses `text`, `bcc`, `from`, `replyTo` rather than dropping them. | A silently lost bcc is a compliance problem found a year later. |
@@ -0,0 +1,106 @@
1
+ // Dates and times as they move between databases — the ONE copy every driver
2
+ // uses, so no driver can quietly read a value in this machine's zone.
3
+ //
4
+ // Three logical types, and three rules:
5
+ //
6
+ // date a calendar day. Copied as the same day, always.
7
+ // datetime an INSTANT (MySQL TIMESTAMP, Postgres timestamptz, SQL Server
8
+ // datetimeoffset, DuckDB TIMESTAMPTZ). Copied as the same
9
+ // instant, in UTC.
10
+ // localdatetime a wall-clock reading with NO zone (MySQL DATETIME, Postgres
11
+ // timestamp, SQL Server datetime/datetime2, DuckDB TIMESTAMP).
12
+ // Copied digit for digit — 10:00 stays 10:00. What zone it was
13
+ // recorded in is declared later, where it is used (a cube's
14
+ // source timezone), never guessed here.
15
+ //
16
+ // NOTHING HERE READS THE MACHINE'S ZONE. Every Date is read with getUTC*, and a
17
+ // string without a zone is read as UTC rather than handed to `new Date()`,
18
+ // which would read it in local time. The drivers deliver values on the same
19
+ // terms: the MySQL session is pinned to UTC, the SQL Server driver to useUTC,
20
+ // Postgres returns its zoneless types as text, and DuckDB works in UTC. So a
21
+ // value here means the same thing on a server in India and one in New York —
22
+ // the tests run under both zones to keep it that way.
23
+
24
+ function pad(n, width) { return String(n).padStart(width || 2, '0'); }
25
+
26
+ function utcDate(d) {
27
+ return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate());
28
+ }
29
+
30
+ function utcClock(d) {
31
+ return pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds()) + '.' + pad(d.getUTCMilliseconds(), 3);
32
+ }
33
+
34
+ // '2025-08-01', '2025-08-01 10:00', '2025-08-01T10:00:00.123456', optionally with
35
+ // 'Z' or an offset. Captures the parts as written.
36
+ var STAMP = /^(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?$/i;
37
+
38
+ /**
39
+ * A calendar day as YYYY-MM-DD. A Date is read in UTC — every driver delivers a
40
+ * date as midnight UTC of that day, or as text.
41
+ */
42
+ function toDateOnly(v) {
43
+ if (v === null || v === undefined) return v;
44
+ if (v instanceof Date) return isNaN(v.getTime()) ? null : utcDate(v);
45
+ var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v).trim());
46
+ return m ? m[1] : v;
47
+ }
48
+
49
+ /**
50
+ * A zoneless wall-clock time as 'YYYY-MM-DD HH:MM:SS.mmm', the digits it had.
51
+ * A Date carries those digits in its UTC fields (that is how the drivers hand
52
+ * one over); a string keeps its own digits, and any zone on it is dropped —
53
+ * the column has none to keep.
54
+ */
55
+ function toLocalDateTime(v) {
56
+ if (v === null || v === undefined) return v;
57
+ if (v instanceof Date) return isNaN(v.getTime()) ? null : utcDate(v) + ' ' + utcClock(v);
58
+ var m = STAMP.exec(String(v).trim());
59
+ if (!m) return v;
60
+ var ms = m[5] ? (m[5] + '00').slice(0, 3) : '000';
61
+ return m[1] + ' ' + (m[2] || '00') + ':' + (m[3] || '00') + ':' + (m[4] || '00') + '.' + ms;
62
+ }
63
+
64
+ /**
65
+ * An instant as a Date. A string that names its zone is read in it; one that
66
+ * does not is read as UTC (a MySQL TIMESTAMP from a UTC session), never in this
67
+ * machine's zone. Unreadable → null.
68
+ */
69
+ function toInstant(v) {
70
+ if (v === null || v === undefined) return null;
71
+ if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
72
+ var m = STAMP.exec(String(v).trim());
73
+ if (!m) return null;
74
+ var iso = m[1] + 'T' + (m[2] || '00') + ':' + (m[3] || '00') + ':' + (m[4] || '00') +
75
+ (m[5] ? '.' + (m[5] + '00').slice(0, 3) : '') + (m[6] ? normalizeZone(m[6]) : 'Z');
76
+ var d = new Date(iso);
77
+ return isNaN(d.getTime()) ? null : d;
78
+ }
79
+
80
+ function normalizeZone(z) {
81
+ if (/^z$/i.test(z)) return 'Z';
82
+ var m = /^([+-])(\d{2}):?(\d{2})?$/.exec(z);
83
+ return m ? m[1] + m[2] + ':' + (m[3] || '00') : 'Z';
84
+ }
85
+
86
+ /** An instant as ISO 8601 in UTC ('…Z'), or the value unchanged when unreadable. */
87
+ function toInstantIso(v) {
88
+ if (v === null || v === undefined) return v;
89
+ var d = toInstant(v);
90
+ return d ? d.toISOString() : v;
91
+ }
92
+
93
+ /** An instant as 'YYYY-MM-DD HH:MM:SS.mmm' in UTC — for a column with no zone of its own (MySQL, SQL Server). */
94
+ function toInstantUtcClock(v) {
95
+ if (v === null || v === undefined) return v;
96
+ var d = toInstant(v);
97
+ return d ? utcDate(d) + ' ' + utcClock(d) : v;
98
+ }
99
+
100
+ module.exports = {
101
+ toDateOnly: toDateOnly,
102
+ toLocalDateTime: toLocalDateTime,
103
+ toInstant: toInstant,
104
+ toInstantIso: toInstantIso,
105
+ toInstantUtcClock: toInstantUtcClock
106
+ };
@@ -81,7 +81,8 @@ var TYPE_MAP = {
81
81
  number: 'DOUBLE',
82
82
  boolean: 'BOOLEAN',
83
83
  date: 'DATE',
84
- datetime: 'TIMESTAMPTZ',
84
+ datetime: 'TIMESTAMPTZ', // an instant
85
+ localdatetime: 'TIMESTAMP', // no zone; its digits as they were (dateValues.js)
85
86
  object: 'JSON',
86
87
  array: 'JSON'
87
88
  };
@@ -94,8 +95,10 @@ var DUCK_TO_LOGICAL = {
94
95
  'HUGEINT': 'number', 'UTINYINT': 'number', 'USMALLINT': 'number', 'UINTEGER': 'number',
95
96
  'UBIGINT': 'number', 'FLOAT': 'number', 'DOUBLE': 'number', 'DECIMAL': 'number',
96
97
  'DATE': 'date',
97
- 'TIMESTAMP': 'datetime', 'TIMESTAMP WITH TIME ZONE': 'datetime',
98
- 'TIMESTAMP_S': 'datetime', 'TIMESTAMP_MS': 'datetime', 'TIMESTAMP_NS': 'datetime',
98
+ // A TIMESTAMP has no zone — a wall-clock reading, copied as its digits. Only
99
+ // the one WITH a zone is an instant.
100
+ 'TIMESTAMP': 'localdatetime', 'TIMESTAMP WITH TIME ZONE': 'datetime',
101
+ 'TIMESTAMP_S': 'localdatetime', 'TIMESTAMP_MS': 'localdatetime', 'TIMESTAMP_NS': 'localdatetime',
99
102
  'TIME': 'datetime', 'TIME WITH TIME ZONE': 'datetime', 'INTERVAL': 'string',
100
103
  'JSON': 'object', 'STRUCT': 'object', 'MAP': 'object',
101
104
  'LIST': 'array'
@@ -234,7 +237,15 @@ async function connect(config) {
234
237
  return existing.pool;
235
238
  }
236
239
 
237
- var options = access === 'ro' ? { access_mode: 'READ_ONLY' } : {};
240
+ // THE SESSION ZONE IS UTC, ALWAYS. DuckDB otherwise takes the machine's
241
+ // zone, and every date operation on a TIMESTAMPTZ — date_trunc, comparing
242
+ // with a plain date, a cast to DATE — happens in it. On a server in India
243
+ // the month of 1 Aug 00:00 UTC came back as 31 Jul 18:30, a board asked for
244
+ // August labelled its total "Jul-2025", and a filter "to 31 Aug" ended at
245
+ // 30 Aug 18:30. Data here is kept in UTC (and a cube stores its days already
246
+ // cut in its own reporting zone), so UTC is the zone that changes nothing.
247
+ // Set on the instance, so every connection to the file inherits it.
248
+ var options = Object.assign({ TimeZone: 'UTC' }, access === 'ro' ? { access_mode: 'READ_ONLY' } : {});
238
249
  // Waits rather than failing — see openWithWait. This is what makes the
239
250
  // file's own lock the mutex and removes any need to designate a writer.
240
251
  var instance = await openWithWait(api, key, options, access,
@@ -516,19 +527,8 @@ function buildInsertSql(tableName, rows, columns, movementId, primaryKeys) {
516
527
  return { sql: sql, params: params };
517
528
  }
518
529
 
519
- // A calendar date as YYYY-MM-DD with no timezone conversion — see the note in
520
- // postgres.js; the bug it avoids is identical here.
521
- function toDateOnly(v) {
522
- if (v === null || v === undefined) return v;
523
- if (v instanceof Date) {
524
- if (isNaN(v.getTime())) return null;
525
- return v.getFullYear() + '-' +
526
- String(v.getMonth() + 1).padStart(2, '0') + '-' +
527
- String(v.getDate()).padStart(2, '0');
528
- }
529
- var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v));
530
- return m ? m[1] : v;
531
- }
530
+ // Dates and times: one shared rule for every driver (dateValues.js).
531
+ var dateValues = require('./dateValues');
532
532
 
533
533
  // Coerce a JS value to the effective column type. Same contract as the other
534
534
  // drivers: a value that cannot fit passes through unchanged so DuckDB rejects
@@ -567,11 +567,13 @@ function coerceValue(v, type) {
567
567
  return v;
568
568
 
569
569
  case 'date':
570
- return toDateOnly(v);
570
+ return dateValues.toDateOnly(v);
571
571
 
572
572
  case 'datetime':
573
- if (v instanceof Date) return v.toISOString();
574
- return v;
573
+ return dateValues.toInstantIso(v);
574
+
575
+ case 'localdatetime':
576
+ return dateValues.toLocalDateTime(v);
575
577
 
576
578
  case 'object':
577
579
  case 'array':
@@ -955,7 +957,17 @@ async function committedMovement(pool, movementId) {
955
957
  return r.rows[0] || null;
956
958
  }
957
959
 
960
+ /**
961
+ * A zoned date-time column that holds zoneless values, retyped to zoneless with
962
+ * the same digits (read in UTC — how they were written). See the uploader's
963
+ * bootstrap for when this runs.
964
+ */
965
+ function buildRetypeToLocalDateTimeSql(tableName, column) {
966
+ return 'ALTER TABLE ' + quoteIdent(tableName) + ' ALTER COLUMN ' + quoteIdent(column) + ' SET DATA TYPE TIMESTAMP';
967
+ }
968
+
958
969
  module.exports = {
970
+ buildRetypeToLocalDateTimeSql: buildRetypeToLocalDateTimeSql,
959
971
  requires: ['@duckdb/node-api'],
960
972
 
961
973
  connect: connect,
@@ -22,8 +22,9 @@
22
22
  // number → DECIMAL(38,10) (38 is SQL Server's max precision; > 10
23
23
  // fractional digits are rounded)
24
24
  // boolean → BIT
25
- // date → DATETIME2 (user rule: always datetime, stored UTC)
26
- // datetime→ DATETIME2
25
+ // date → DATE (the same day — dateValues.js)
26
+ // datetime → DATETIME2 (an instant, written in UTC)
27
+ // localdatetime → DATETIME2 (no zone; its digits as they were)
27
28
  // object → NVARCHAR(MAX) (SQL Server has no native JSON type)
28
29
  // array → NVARCHAR(MAX)
29
30
 
@@ -33,6 +34,7 @@ var TYPE_MAP = {
33
34
  boolean: 'BIT',
34
35
  date: 'DATE',
35
36
  datetime: 'DATETIME2',
37
+ localdatetime: 'DATETIME2',
36
38
  object: 'NVARCHAR(MAX)',
37
39
  array: 'NVARCHAR(MAX)'
38
40
  };
@@ -45,30 +47,16 @@ var NATIVE_TO_LOGICAL = {
45
47
  decimal: 'number', numeric: 'number', float: 'number', real: 'number',
46
48
  money: 'number', smallmoney: 'number',
47
49
  bit: 'boolean',
48
- datetime: 'datetime', datetime2: 'datetime', smalldatetime: 'datetime',
50
+ // No zone: copied as their digits (dateValues.js). datetimeoffset is an
51
+ // instant.
52
+ datetime: 'localdatetime', datetime2: 'localdatetime', smalldatetime: 'localdatetime',
49
53
  // 'date' maps to its OWN logical type — see reconcile.js.
50
54
  date: 'date', time: 'datetime', datetimeoffset: 'datetime'
51
55
  };
52
56
 
53
57
 
54
- // A calendar date, as YYYY-MM-DD, with NO timezone conversion.
55
- //
56
- // toISOString() would convert to UTC first, which is exactly the bug: a Date
57
- // holding 2026-01-05 local becomes 2026-01-04 in UTC anywhere east of
58
- // Greenwich. The local getters are the right ones here precisely because a
59
- // business date has no instant to convert.
60
- function toDateOnly(v) {
61
- if (v === null || v === undefined) return v;
62
- if (v instanceof Date) {
63
- if (isNaN(v.getTime())) return null;
64
- return v.getFullYear() + '-' +
65
- String(v.getMonth() + 1).padStart(2, '0') + '-' +
66
- String(v.getDate()).padStart(2, '0');
67
- }
68
- // Already a date string — take the date part and leave the rest alone.
69
- var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v));
70
- return m ? m[1] : v;
71
- }
58
+ // Dates and times: one shared rule for every driver (dateValues.js).
59
+ var dateValues = require('./dateValues');
72
60
 
73
61
  var INTERNAL_ID_COL = '__xeplr_id__';
74
62
  var INTERNAL_MOVEMENT_COL = '__xeplr_movement_id__';
@@ -92,7 +80,12 @@ async function connect(config) {
92
80
  // batch and has no session-level read-only mode, so SQL the caller did
93
81
  // not write needs a login that can only read (db_datareader). See README.
94
82
  config.readOnly ? { readOnlyIntent: true } : {},
95
- config.options || {}
83
+ config.options || {},
84
+ // PINNED, after the caller's options: a zoneless datetime/date comes
85
+ // back with its digits in the Date's UTC fields, whatever zone this
86
+ // process is in. It was the library's default, which a default change
87
+ // or a caller's `useUTC: false` would have silently undone.
88
+ { useUTC: true }
96
89
  )
97
90
  });
98
91
  await pool.connect();
@@ -265,7 +258,7 @@ function coerceValue(v, type) {
265
258
 
266
259
  switch (type) {
267
260
  case 'string':
268
- if (v instanceof Date) return toMssqlDatetime(v);
261
+ if (v instanceof Date) return dateValues.toInstantUtcClock(v);
269
262
  if (Array.isArray(v)) return JSON.stringify(v);
270
263
  if (typeof v === 'object') return JSON.stringify(v);
271
264
  return String(v);
@@ -289,10 +282,13 @@ function coerceValue(v, type) {
289
282
  return v; // let SQL Server reject
290
283
 
291
284
  case 'date':
292
- return toDateOnly(v);
285
+ return dateValues.toDateOnly(v);
293
286
 
294
287
  case 'datetime':
295
- return toMssqlDatetime(v);
288
+ return dateValues.toInstantUtcClock(v);
289
+
290
+ case 'localdatetime':
291
+ return dateValues.toLocalDateTime(v);
296
292
 
297
293
  case 'object':
298
294
  case 'array':
@@ -304,13 +300,6 @@ function coerceValue(v, type) {
304
300
  }
305
301
  }
306
302
 
307
- // 'YYYY-MM-DD HH:MM:SS.mmm' in UTC — DATETIME2 parses this unambiguously.
308
- // Unparseable input passes through so SQL Server rejects it (→ error table).
309
- function toMssqlDatetime(v) {
310
- var d = (v instanceof Date) ? v : new Date(v);
311
- if (isNaN(d.getTime())) return v;
312
- return d.toISOString().slice(0, 23).replace('T', ' ');
313
- }
314
303
 
315
304
  async function getTableSchema(pool, tableName, schema) {
316
305
  var r = await query(pool,
@@ -19,8 +19,9 @@
19
19
  // number → DECIMAL(38,10) (exact; > 10 fractional digits are rounded —
20
20
  // pre-create a wider column if you need more)
21
21
  // boolean → TINYINT(1)
22
- // date → DATETIME(6) (user rule: always datetime, stored UTC)
23
- // datetime→ DATETIME(6)
22
+ // date → DATE (the same day — dateValues.js)
23
+ // datetime → DATETIME(6) (an instant, written in UTC)
24
+ // localdatetime → DATETIME(6) (no zone; its digits as they were)
24
25
  // object → JSON
25
26
  // array → JSON
26
27
 
@@ -29,7 +30,8 @@ var TYPE_MAP = {
29
30
  number: 'DECIMAL(38,10)',
30
31
  boolean: 'TINYINT(1)',
31
32
  date: 'DATE',
32
- datetime: 'DATETIME(6)',
33
+ datetime: 'DATETIME(6)', // an instant, written in UTC
34
+ localdatetime: 'DATETIME(6)', // a wall-clock reading, written as its digits
33
35
  object: 'JSON',
34
36
  array: 'JSON'
35
37
  };
@@ -43,30 +45,16 @@ var NATIVE_TO_LOGICAL = {
43
45
  float: 'number', double: 'number', bit: 'boolean',
44
46
  // 'date' maps to its OWN logical type — see reconcile.js. A DATE column
45
47
  // created as DATETIME acquires a midnight that shifts under a timezone.
46
- date: 'date', datetime: 'datetime', timestamp: 'datetime',
48
+ // DATETIME has no zone — its digits are copied as they are (dateValues.js).
49
+ // TIMESTAMP is an instant, read in UTC (the session is pinned to it below).
50
+ date: 'date', datetime: 'localdatetime', timestamp: 'datetime',
47
51
  time: 'datetime', year: 'datetime',
48
52
  json: 'object'
49
53
  };
50
54
 
51
55
 
52
- // A calendar date, as YYYY-MM-DD, with NO timezone conversion.
53
- //
54
- // toISOString() would convert to UTC first, which is exactly the bug: a Date
55
- // holding 2026-01-05 local becomes 2026-01-04 in UTC anywhere east of
56
- // Greenwich. The local getters are the right ones here precisely because a
57
- // business date has no instant to convert.
58
- function toDateOnly(v) {
59
- if (v === null || v === undefined) return v;
60
- if (v instanceof Date) {
61
- if (isNaN(v.getTime())) return null;
62
- return v.getFullYear() + '-' +
63
- String(v.getMonth() + 1).padStart(2, '0') + '-' +
64
- String(v.getDate()).padStart(2, '0');
65
- }
66
- // Already a date string — take the date part and leave the rest alone.
67
- var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v));
68
- return m ? m[1] : v;
69
- }
56
+ // Dates and times: one shared rule for every driver (dateValues.js).
57
+ var dateValues = require('./dateValues');
70
58
 
71
59
  var INTERNAL_ID_COL = '__xeplr_id__';
72
60
  var INTERNAL_MOVEMENT_COL = '__xeplr_movement_id__';
@@ -96,6 +84,13 @@ async function connect(config) {
96
84
  stringifyObjects: true
97
85
  // multipleStatements stays at its default (off): one statement per query.
98
86
  });
87
+ // THE SESSION IS IN UTC. MySQL hands a TIMESTAMP back in the session's zone
88
+ // (the server's own unless told), so without this the same instant read
89
+ // from a server set to India time came back 5½ hours off. Run first on
90
+ // every connection the pool opens; mysql2 queues it ahead of any query.
91
+ pool.pool.on('connection', function(conn) {
92
+ conn.query("SET time_zone = '+00:00'");
93
+ });
99
94
  // READ-ONLY CONNECTIONS — `connect({ ..., readOnly: true })` for SQL the
100
95
  // caller did not write itself. Every connection the pool opens is put into
101
96
  // read-only transaction mode before it is used, so a write fails.
@@ -262,7 +257,7 @@ function coerceValue(v, type) {
262
257
 
263
258
  switch (type) {
264
259
  case 'string':
265
- if (v instanceof Date) return toMysqlDatetime(v);
260
+ if (v instanceof Date) return dateValues.toInstantUtcClock(v);
266
261
  if (Array.isArray(v)) return JSON.stringify(v);
267
262
  if (typeof v === 'object') return JSON.stringify(v);
268
263
  return String(v);
@@ -286,10 +281,13 @@ function coerceValue(v, type) {
286
281
  return v; // let MySQL reject
287
282
 
288
283
  case 'date':
289
- return toDateOnly(v);
284
+ return dateValues.toDateOnly(v);
290
285
 
291
286
  case 'datetime':
292
- return toMysqlDatetime(v);
287
+ return dateValues.toInstantUtcClock(v);
288
+
289
+ case 'localdatetime':
290
+ return dateValues.toLocalDateTime(v);
293
291
 
294
292
  case 'object':
295
293
  case 'array':
@@ -301,13 +299,6 @@ function coerceValue(v, type) {
301
299
  }
302
300
  }
303
301
 
304
- // 'YYYY-MM-DD HH:MM:SS.mmm' in UTC — the format MySQL DATETIME accepts.
305
- // Unparseable input passes through so MySQL rejects it (→ error table).
306
- function toMysqlDatetime(v) {
307
- var d = (v instanceof Date) ? v : new Date(v);
308
- if (isNaN(d.getTime())) return v;
309
- return d.toISOString().slice(0, 23).replace('T', ' ');
310
- }
311
302
 
312
303
  async function getTableSchema(pool, tableName, schema) {
313
304
  var r = await query(pool,
@@ -22,8 +22,9 @@
22
22
  // string → TEXT
23
23
  // number → NUMERIC (integer + float both handled)
24
24
  // boolean → BOOLEAN
25
- // date → TIMESTAMPTZ (user rule: "always datetime, never date-only")
26
- // datetime→ TIMESTAMPTZ
25
+ // date → DATE (the same day — dateValues.js)
26
+ // datetime → TIMESTAMPTZ (an instant)
27
+ // localdatetime → TIMESTAMP (no zone; its digits as they were)
27
28
  // object → JSONB
28
29
  // array → JSONB
29
30
 
@@ -33,6 +34,7 @@ var TYPE_MAP = {
33
34
  boolean: 'BOOLEAN',
34
35
  date: 'DATE',
35
36
  datetime: 'TIMESTAMPTZ',
37
+ localdatetime: 'TIMESTAMP',
36
38
  object: 'JSONB',
37
39
  array: 'JSONB'
38
40
  };
@@ -60,7 +62,17 @@ async function connect(config) {
60
62
  user: config.user,
61
63
  password: config.password,
62
64
  database: config.database,
63
- max: config.maxConnections || 10
65
+ max: config.maxConnections || 10,
66
+ // THE ZONELESS TYPES COME BACK AS TEXT. node-pg turns a `date` and a
67
+ // `timestamp` into a JS Date read in THIS PROCESS's zone, so the same row
68
+ // read on a server in India and one in New York came back as two
69
+ // different instants — and a date a day apart. As text they are exactly
70
+ // the digits stored. `timestamptz` (1184) is a real instant and stays a
71
+ // Date.
72
+ types: { getTypeParser: function(oid, format) {
73
+ if (oid === 1082 || oid === 1114) return function(text) { return text; };
74
+ return pg.types.getTypeParser(oid, format);
75
+ } }
64
76
  });
65
77
  if (config.readOnly) pool.__xeplrReadOnly = true;
66
78
  return pool;
@@ -98,24 +110,9 @@ async function query(pool, sql, params) {
98
110
 
99
111
  function toPgType(type) { return TYPE_MAP[type] || 'TEXT'; }
100
112
 
101
- // A calendar date, as YYYY-MM-DD, with NO timezone conversion.
102
- //
103
- // toISOString() would convert to UTC first, which is exactly the bug: a Date
104
- // holding 2026-01-05 local becomes 2026-01-04 in UTC anywhere east of
105
- // Greenwich. The local getters are the right ones here precisely because a
106
- // business date has no instant to convert.
107
- function toDateOnly(v) {
108
- if (v === null || v === undefined) return v;
109
- if (v instanceof Date) {
110
- if (isNaN(v.getTime())) return null;
111
- return v.getFullYear() + '-' +
112
- String(v.getMonth() + 1).padStart(2, '0') + '-' +
113
- String(v.getDate()).padStart(2, '0');
114
- }
115
- // Already a date string — take the date part and leave the rest alone.
116
- var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v));
117
- return m ? m[1] : v;
118
- }
113
+ // Dates and times: one shared rule for every driver (dateValues.js).
114
+ var dateValues = require('./dateValues');
115
+ var toDateOnly = dateValues.toDateOnly;
119
116
 
120
117
 
121
118
  // Double-quote a Postgres identifier, escaping embedded quotes.
@@ -251,8 +248,10 @@ function coerceValue(v, type) {
251
248
  return toDateOnly(v);
252
249
 
253
250
  case 'datetime':
254
- if (v instanceof Date) return v.toISOString();
255
- return v; // strings pass through; PG parses
251
+ return dateValues.toInstantIso(v);
252
+
253
+ case 'localdatetime':
254
+ return dateValues.toLocalDateTime(v);
256
255
 
257
256
  case 'object':
258
257
  case 'array':
@@ -395,7 +394,17 @@ async function rollbackMovement(pool, tableName, movementId) {
395
394
  return result;
396
395
  }
397
396
 
397
+ /**
398
+ * A zoned date-time column that holds zoneless values, retyped to zoneless with
399
+ * the same digits (read in UTC — how they were written). See the uploader's
400
+ * bootstrap for when this runs.
401
+ */
402
+ function buildRetypeToLocalDateTimeSql(tableName, column) {
403
+ return 'ALTER TABLE ' + quoteIdent(tableName) + ' ALTER COLUMN ' + quoteIdent(column) + ' TYPE timestamp USING ' + quoteIdent(column) + " AT TIME ZONE 'UTC'";
404
+ }
405
+
398
406
  module.exports = {
407
+ buildRetypeToLocalDateTimeSql: buildRetypeToLocalDateTimeSql,
399
408
  requires: ['pg'],
400
409
 
401
410
  // Connection lifecycle
@@ -17,6 +17,7 @@
17
17
  // same memory as a hundred. Only the first SAMPLE_ROWS are held, to size the
18
18
  // columns, because <cols> must come before the rows in the sheet.
19
19
 
20
+ var dateValues = require('../../drivers/db/dateValues');
20
21
  var fs = require('fs');
21
22
 
22
23
  var MAX_ROWS = 1048576; // Excel's own limit, header and title rows included
@@ -38,9 +39,14 @@ function colName(i) {
38
39
  return s;
39
40
  }
40
41
 
41
- /** A Date, or an ISO date string, as an Excel serial — or null. */
42
+ /**
43
+ * A Date, or an ISO date string, as an Excel serial — or null. A string with
44
+ * no zone is read as UTC (dateValues.toInstant), never in this machine's zone:
45
+ * `new Date('2025-08-01 10:00')` would put a different time in the cell on a
46
+ * server in India than on one in New York.
47
+ */
42
48
  function dateSerial(value) {
43
- var d = value instanceof Date ? value : (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? new Date(value) : null);
49
+ var d = value instanceof Date ? value : (typeof value === 'string' ? dateValues.toInstant(value) : null);
44
50
  if (!d || isNaN(d.getTime())) return null;
45
51
  return d.getTime() / 86400000 + 25569;
46
52
  }
@@ -410,6 +410,20 @@ async function runBootstrap(driver, pool, targetTable, columns, primaryKeys, sta
410
410
  var { reconcileColumns } = require('./reconcile');
411
411
  var reconciled = reconcileColumns(columns, targetSchema, driver.dataTypeToLogical);
412
412
 
413
+ // A ZONELESS SOURCE COLUMN IN A ZONED TARGET COLUMN. Tables created before
414
+ // zoneless date-times had their own type hold them as instants (the digits
415
+ // read as UTC). The target's type would win and keep it so forever — so it
416
+ // is retyped to zoneless, once, keeping those same digits. Only where the
417
+ // dialect has the two kinds (Postgres, DuckDB); MySQL DATETIME and SQL
418
+ // Server DATETIME2 are zoneless already.
419
+ var retype = reconciled.filter(function(c) { return c.sourceType === 'localdatetime' && c.targetType === 'datetime'; });
420
+ if (retype.length && typeof driver.buildRetypeToLocalDateTimeSql === 'function') {
421
+ for (var r = 0; r < retype.length; r++) {
422
+ await driver.query(pool, driver.buildRetypeToLocalDateTimeSql(targetTable, retype[r].name));
423
+ }
424
+ reconciled = reconcileColumns(columns, await driver.getTableSchema(pool, targetTable), driver.dataTypeToLogical);
425
+ }
426
+
413
427
  var missing = reconciled.filter(function(c) { return c.missing; });
414
428
  if (missing.length > 0) {
415
429
  throw new Error(
@@ -29,7 +29,8 @@ var PG_TO_LOGICAL = {
29
29
  'double precision': 'number',
30
30
  'boolean': 'boolean',
31
31
  'timestamp with time zone': 'datetime',
32
- 'timestamp without time zone': 'datetime',
32
+ // No zone: copied as its digits, never as an instant (drivers/db/dateValues.js).
33
+ 'timestamp without time zone': 'localdatetime',
33
34
  // A DATE IS NOT A TIMESTAMP, and mapping it to one moves it.
34
35
  //
35
36
  // This said 'datetime', so a source `date` was created as TIMESTAMPTZ in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/actions",
3
- "version": "1.1.19",
3
+ "version": "1.1.20",
4
4
  "description": "Named-action registry with typed inputs and safe invocation — plus built-in actions for HTTP, DB, files, email, spawn",
5
5
  "main": "index.js",
6
6
  "files": [