@ak--47/dungeon-master 1.6.5 → 1.8.0

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.
Files changed (45) hide show
  1. package/.claude/skills/analyze-soup/SKILL.md +21 -11
  2. package/.claude/skills/create-dungeon/SKILL.md +49 -10
  3. package/.claude/skills/create-project/SKILL.md +22 -3
  4. package/.claude/skills/create-project/context.mjs +89 -0
  5. package/.claude/skills/create-project/provision.mjs +1 -60
  6. package/.claude/skills/headless-build/SKILL.md +18 -1
  7. package/.claude/skills/powertools/SKILL.md +20 -1
  8. package/.claude/skills/release-check/SKILL.md +99 -0
  9. package/.claude/skills/verify-dungeon/SKILL.md +71 -16
  10. package/.claude/skills/verify-dungeon/references/counting-semantics.md +14 -0
  11. package/.claude/skills/verify-dungeon/references/report-format.md +18 -1
  12. package/.claude/skills/verify-dungeon/references/sql-recipes.md +36 -1
  13. package/.claude/skills/warehouse-metrics/GAPS-template.md +34 -0
  14. package/.claude/skills/warehouse-metrics/SKILL.md +105 -0
  15. package/.claude/skills/warehouse-metrics/deploy.mjs +651 -0
  16. package/.claude/skills/write-hooks/SKILL.md +33 -3
  17. package/CHANGELOG.md +331 -0
  18. package/HOOKS.md +154 -5
  19. package/README.md +357 -8
  20. package/docs/guides/1.7.0-upgrade-guide.md +154 -0
  21. package/docs/guides/1.8.0-upgrade-guide.md +151 -0
  22. package/dungeons/technical/warehouse.js +187 -0
  23. package/index.js +131 -2
  24. package/lib/core/config-validator.js +264 -13
  25. package/lib/core/context.js +39 -0
  26. package/lib/core/dungeon-loader.js +5 -2
  27. package/lib/core/storage.js +51 -3
  28. package/lib/generators/events.js +53 -7
  29. package/lib/generators/funnels.js +85 -11
  30. package/lib/generators/profiles.js +9 -4
  31. package/lib/generators/standalone.js +248 -0
  32. package/lib/generators/warehouse.js +828 -0
  33. package/lib/orchestrators/mixpanel-sender.js +39 -3
  34. package/lib/orchestrators/user-loop.js +240 -9
  35. package/lib/templates/story-spec.schema.json +41 -16
  36. package/lib/utils/conditions.js +62 -0
  37. package/lib/utils/json-evaluator.js +12 -2
  38. package/lib/utils/utils.js +115 -19
  39. package/lib/verify/index.js +1 -0
  40. package/lib/verify/schema-validator.js +8 -0
  41. package/lib/verify/story-runner.js +71 -8
  42. package/lib/verify/warehouse.js +683 -0
  43. package/package.json +5 -11
  44. package/scripts/verify-stories.mjs +150 -44
  45. package/types.d.ts +606 -38
@@ -63,6 +63,51 @@ function resetValueCaches() {
63
63
  winnerEntryCache = new WeakMap();
64
64
  winnerCache.clear();
65
65
  weightedArrayCache.clear();
66
+ autoPowerLawEnabled = true;
67
+ }
68
+
69
+ // v1.7.0 (P2-1): run-level switch for the implicit power-law draw on 3–19-item
70
+ // unique-string arrays. `choose()` is a free function with no config access, so
71
+ // the orchestrator sets this from `validatedConfig.autoPowerLaw` at run start
72
+ // (mirrors how the seeded chance singleton is managed). resetValueCaches()
73
+ // restores the default so a prior run's opt-out never leaks.
74
+ let autoPowerLawEnabled = true;
75
+
76
+ /** @param {boolean} enabled */
77
+ function setAutoPowerLaw(enabled) {
78
+ autoPowerLawEnabled = enabled !== false;
79
+ }
80
+
81
+ /** @returns {boolean} */
82
+ function getAutoPowerLaw() {
83
+ return autoPowerLawEnabled;
84
+ }
85
+
86
+ /**
87
+ * v1.7.0 (P1-1): true for a value function that DECLARES a parameter (`(ctx) => …`)
88
+ * and is not a bound native (`chance.animal.bind(chance)` reports the native's
89
+ * arity but cannot read ctx). Context-aware functions skip the source-string
90
+ * cache and, on funnel steps, resolve inside makeEvent with the real event ctx.
91
+ * @param {unknown} value
92
+ * @returns {value is (ctx?: any) => any}
93
+ */
94
+ function isContextAware(value) {
95
+ return typeof value === 'function' && value.length >= 1 && !Function.prototype.toString.call(value).includes('[native code]');
96
+ }
97
+
98
+ /**
99
+ * v1.7.0 (P2-1): `{ __weights: { free: 60, pro: 30, enterprise: 10 } }` — the
100
+ * declarative weighted form. True when `value` carries a non-empty `__weights`
101
+ * object whose values are finite, non-negative numbers.
102
+ * @param {unknown} value
103
+ * @returns {value is { __weights: Record<string, number> }}
104
+ */
105
+ function isWeightsForm(value) {
106
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
107
+ const w = /** @type {any} */ (value).__weights;
108
+ if (!w || typeof w !== 'object' || Array.isArray(w)) return false;
109
+ const entries = Object.entries(w);
110
+ return entries.length > 0 && entries.every(([, n]) => typeof n === 'number' && Number.isFinite(n) && n >= 0);
66
111
  }
67
112
 
68
113
  // v1.5.1: dataset-window state moved to AsyncLocalStorage scope
@@ -400,15 +445,24 @@ function objectList(template, options = {}) {
400
445
  * similar to pick
401
446
  * @param {ValueValid} value
402
447
  */
403
- function choose(value) {
448
+ function choose(value, ctx = undefined) {
404
449
  if (value instanceof ListValue) return value;
405
450
  const chance = getChance();
406
451
 
407
- // most of the time this will receive a list of strings;
452
+ // v1.7.0 (P2-1): declarative weights. The author's numbers ARE the
453
+ // distribution — no power law, no winner memo. Zero-weight keys never draw.
454
+ if (isWeightsForm(value)) {
455
+ const entries = Object.entries(value.__weights).filter(([, n]) => n > 0);
456
+ if (!entries.length) return "";
457
+ return chance.weighted(entries.map(([k]) => k), entries.map(([, n]) => n));
458
+ }
459
+
460
+ // most of the time this will receive a list of strings;
408
461
  // when that is the case, we need to ensure some 'keywords' like 'variant' or 'test' aren't in the array
409
462
  // next we want to see if the array is unweighted ... i.e. no dupe strings and each string only occurs once ['a', 'b', 'c', 'd']
410
463
  // if all these are true we will pickAWinner(value)()
411
- if (Array.isArray(value) && value.length > 2 && value.length < 20 && value.every(item => typeof item === 'string')) {
464
+ // v1.7.0 (P2-1): `autoPowerLaw: false` skips this branch uniform pickone below.
465
+ if (autoPowerLawEnabled && Array.isArray(value) && value.length > 2 && value.length < 20 && value.every(item => typeof item === 'string')) {
412
466
  // ensure terms 'variant' 'group' 'experiment' or 'population' are NOT in any of the items
413
467
  if (!value.some(item => item.includes('variant') || item.includes('group') || item.includes('experiment') || item.includes('population'))) {
414
468
  // check to make sure that each element in the array only occurs once...
@@ -439,21 +493,32 @@ function choose(value) {
439
493
  // Functions tagged noCache (pickAWinner closures) skip the source-string
440
494
  // cache: their toString() is identical across instances, so caching by
441
495
  // source would hand one property's expansion to every other property.
496
+ //
497
+ // v1.7.0 (P1-1): value functions receive a `ValueContext` (`{ profile,
498
+ // event, time, config }`, members optional). Any function with declared
499
+ // arity >= 1 is context-aware and ALSO skips the source-string cache —
500
+ // its result legitimately differs per user/event, so caching by source
501
+ // would freeze the first evaluation and hand it to every later caller.
502
+ // Zero-arity functions keep the pre-1.7 cache behavior exactly.
442
503
  while (typeof value === 'function') {
443
- if (/** @type {any} */ (value).noCache === true) {
444
- const result = value();
504
+ // Bound natives (`chance.animal.bind(chance)`) report the native's arity
505
+ // and cannot read ctx; call them exactly as before (no argument) so the
506
+ // options object chance methods take never sees the context.
507
+ const funcString = value.toString();
508
+ const isNative = funcString.includes('[native code]');
509
+ if (/** @type {any} */ (value).noCache === true || (value.length >= 1 && !isNative)) {
510
+ const result = isNative ? value() : value(ctx);
445
511
  if (result instanceof ListValue) return result;
446
512
  value = result;
447
513
  continue;
448
514
  }
449
- const funcString = value.toString();
450
515
 
451
516
  if (weightedArrayCache.has(funcString)) {
452
517
  value = weightedArrayCache.get(funcString);
453
518
  break;
454
519
  }
455
520
 
456
- const result = value();
521
+ const result = isNative ? value() : value(ctx);
457
522
  if (result instanceof ListValue) return result;
458
523
  if (Array.isArray(result) && result.length > 10) {
459
524
  // Cache large arrays (likely weighted arrays)
@@ -463,6 +528,8 @@ function choose(value) {
463
528
  }
464
529
 
465
530
  if (value instanceof ListValue) return value;
531
+ // A function may return the weighted form.
532
+ if (isWeightsForm(value)) return choose(value, ctx);
466
533
 
467
534
  if (Array.isArray(value) && value.length === 0) {
468
535
  return ""; // Return empty string if the array is empty
@@ -748,10 +815,26 @@ function streamJSON(filePath, data, options = {}) {
748
815
  });
749
816
  }
750
817
 
818
+ function csvRow(item, columns) {
819
+ return columns.map(col => {
820
+ const value = item[col];
821
+
822
+ if (value === null || value === undefined) {
823
+ return '';
824
+ }
825
+
826
+ const serialized = typeof value === 'object'
827
+ ? JSON.stringify(value)
828
+ : value.toString();
829
+
830
+ return `"${serialized.replace(/"/g, '""')}"`;
831
+ }).join(',');
832
+ }
833
+
751
834
  function streamCSV(filePath, data, options = {}) {
752
835
  return new Promise((resolve, reject) => {
753
836
  let writeStream;
754
- const { gzip = false } = options;
837
+ const { gzip = false, fixedColumns } = options;
755
838
 
756
839
  if (filePath?.startsWith('gs://')) {
757
840
  const { uri, bucket, file } = parseGCSUri(filePath);
@@ -775,20 +858,12 @@ function streamCSV(filePath, data, options = {}) {
775
858
  }
776
859
  }
777
860
 
778
- // Extract all unique keys from the data array
779
- const columns = getUniqueKeys(data); // Assuming getUniqueKeys properly retrieves all keys
861
+ const columns = Array.isArray(fixedColumns) ? fixedColumns : getUniqueKeys(data);
780
862
 
781
- // Stream the header
782
863
  writeStream.write(columns.join(',') + '\n');
783
864
 
784
- // Stream each data row
785
865
  data.forEach(item => {
786
- for (const key in item) {
787
- // Ensure all nested objects are properly stringified
788
- if (typeof item[key] === "object") item[key] = JSON.stringify(item[key]);
789
- }
790
- const row = columns.map(col => item[col] ? `"${item[col].toString().replace(/"/g, '""')}"` : "").join(',');
791
- writeStream.write(row + '\n');
866
+ writeStream.write(csvRow(item, columns) + '\n');
792
867
  });
793
868
 
794
869
  writeStream.end();
@@ -1303,7 +1378,7 @@ META
1303
1378
  * @param {Config} config
1304
1379
  */
1305
1380
  function buildFileNames(config) {
1306
- const { format = "csv", groupKeys = [], lookupTables = [] } = config;
1381
+ const { format = "csv", groupKeys = [], lookupTables = [], warehouseMetrics = [] } = config;
1307
1382
  let extension = "";
1308
1383
  extension = format === "csv" ? "csv" : "json";
1309
1384
  // const current = dayjs.utc().format("MM-DD-HH");
@@ -1321,10 +1396,12 @@ function buildFileNames(config) {
1321
1396
  eventFiles: [path.join(writeDir, `${simName}-EVENTS.${extension}`)],
1322
1397
  userFiles: [path.join(writeDir, `${simName}-USERS.${extension}`)],
1323
1398
  adSpendFiles: [],
1399
+ standaloneFiles: [],
1324
1400
  scdFiles: [],
1325
1401
  mirrorFiles: [],
1326
1402
  groupFiles: [],
1327
1403
  lookupFiles: [],
1404
+ warehouseFiles: [],
1328
1405
  folder: writeDir,
1329
1406
  };
1330
1407
  //add ad spend files
@@ -1332,6 +1409,11 @@ function buildFileNames(config) {
1332
1409
  writePaths.adSpendFiles.push(path.join(writeDir, `${simName}-AD-SPEND.${extension}`));
1333
1410
  }
1334
1411
 
1412
+ //add standalone (identity-less snapshot) files — v1.8.0
1413
+ if (Array.isArray(config?.standaloneEvents) && config.standaloneEvents.length > 0) {
1414
+ writePaths.standaloneFiles.push(path.join(writeDir, `${simName}-STANDALONE.${extension}`));
1415
+ }
1416
+
1335
1417
  //add SCD files
1336
1418
  const scdKeys = Object.keys(config?.scdProps || {});
1337
1419
  for (const key of scdKeys) {
@@ -1358,6 +1440,15 @@ function buildFileNames(config) {
1358
1440
  );
1359
1441
  }
1360
1442
 
1443
+ for (const warehouseMetric of warehouseMetrics) {
1444
+ const metricName = warehouseMetric?.name;
1445
+ if (typeof metricName !== 'string') continue;
1446
+ const metricFormat = warehouseMetric?.format || format || 'csv';
1447
+ writePaths.warehouseFiles.push(
1448
+ path.join(writeDir, `${simName}-WAREHOUSE-${metricName}.${metricFormat}`)
1449
+ );
1450
+ }
1451
+
1361
1452
  //add mirror files
1362
1453
  const mirrorProps = config?.mirrorProps || {};
1363
1454
  if (Object.keys(mirrorProps).length) {
@@ -1864,6 +1955,10 @@ export {
1864
1955
  person,
1865
1956
  pickAWinner,
1866
1957
  resetValueCaches,
1958
+ setAutoPowerLaw,
1959
+ getAutoPowerLaw,
1960
+ isWeightsForm,
1961
+ isContextAware,
1867
1962
  quickHash,
1868
1963
  weighArray,
1869
1964
  validateEventConfig,
@@ -1877,6 +1972,7 @@ export {
1877
1972
  generateUser,
1878
1973
  optimizedBoxMuller,
1879
1974
  buildFileNames,
1975
+ csvRow,
1880
1976
  streamJSON,
1881
1977
  streamCSV,
1882
1978
  streamParquet,
@@ -17,6 +17,7 @@ export { verifyDungeon } from './verify-dungeon.js';
17
17
  // `evaluateStories` / `applyFunnelDefaults`. Use the RETURN value; it does not
18
18
  // enrich the config you pass in.
19
19
  export { validateDungeonConfig } from '../core/config-validator.js';
20
+ export { pearson, computeWarehouseStats, computeWarehouseSourceRows, auditWarehouseRows } from './warehouse.js';
20
21
  export { deriveExpectedSchema, validateSchema } from './schema-validator.js';
21
22
  export {
22
23
  evaluateFunnel,
@@ -41,6 +41,14 @@ export function deriveExpectedSchema(config) {
41
41
  }
42
42
  }
43
43
 
44
+ // v1.7.0 (P1-2): stickyEventProps are engine-stamped on every event of every
45
+ // user — legal on every event type, not flag stamping.
46
+ if (Array.isArray(config.stickyEventProps)) {
47
+ for (const key of config.stickyEventProps) {
48
+ if (typeof key === 'string' && key) globalKeys.add(key);
49
+ }
50
+ }
51
+
44
52
  if (config.hasLocation) {
45
53
  for (const k of LOCATION_KEYS) globalKeys.add(k);
46
54
  }
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { emulateBreakdown } from './emulate-breakdown.js';
29
29
  import { applyFunnelDefaults } from './verify-dungeon.js';
30
+ import { computeWarehouseSourceRows, computeWarehouseStats } from './warehouse.js';
30
31
 
31
32
  /** Closed archetype enum — MUST match lib/templates/story-spec.schema.json (unit-tested). */
32
33
  export const STORY_ARCHETYPES = [
@@ -322,6 +323,9 @@ export function validateStories(stories) {
322
323
  err(ap, 'breakdown: required object with a string `type`');
323
324
  } else if (a.breakdown.type === 'duckdb' && (typeof a.breakdown.sql !== 'string' || !a.breakdown.sql)) {
324
325
  err(ap, 'breakdown: type "duckdb" requires a non-empty `sql`');
326
+ } else if ((a.breakdown.type === 'warehouse' || a.breakdown.type === 'warehouse-stats')
327
+ && (typeof a.breakdown.table !== 'string' || !a.breakdown.table.trim())) {
328
+ err(ap, `breakdown: type "${a.breakdown.type}" requires a non-empty \`table\``);
325
329
  }
326
330
  if (a.expect === undefined && typeof a.assert !== 'function') {
327
331
  err(ap, 'requires `expect` or a function-valued `assert`');
@@ -339,8 +343,8 @@ export function validateStories(stories) {
339
343
  for (const [name, spec] of Object.entries(a.select)) {
340
344
  if (!NAME_RE.test(name)) err(ap, `select: name "${name}" must be an identifier`);
341
345
  selectNames.add(name);
342
- if (!spec || typeof spec !== 'object' || !spec.where || typeof spec.where !== 'object' || !Object.keys(spec.where).length) {
343
- err(ap, `select.${name}: requires a non-empty \`where\``);
346
+ if (!spec || typeof spec !== 'object' || !spec.where || typeof spec.where !== 'object') {
347
+ err(ap, `select.${name}: requires a \`where\` object`);
344
348
  continue;
345
349
  }
346
350
  for (const [col, cond] of Object.entries(spec.where)) {
@@ -406,8 +410,8 @@ export function storiesToChecks(stories) {
406
410
  for (const story of stories) {
407
411
  story.assertions.forEach((assertion, i) => {
408
412
  const name = `${story.id}[${i}]`;
409
- if (assertion.breakdown.type === 'duckdb') {
410
- console.warn(`[dungeon-master] ${name}: duckdb assertions only run in disk mode (scripts/verify-stories.mjs) — skipped in-memory.`);
413
+ if (assertion.breakdown.type === 'duckdb' || assertion.breakdown.type === 'warehouse' || assertion.breakdown.type === 'warehouse-stats') {
414
+ console.warn(`[dungeon-master] ${name}: ${assertion.breakdown.type} assertions only run in disk mode via scripts/verify-stories.mjs — skipped in verifyDungeon in-memory checks.`);
411
415
  return;
412
416
  }
413
417
  checks.push({
@@ -441,11 +445,34 @@ export function storiesToChecks(stories) {
441
445
  * auto-build over large profile sets is wasteful.
442
446
  * @param {(sql: string) => Promise<Array<Object>>} [opts.runSql] - duckdb
443
447
  * executor (provided by the CLI in disk mode). Absent → duckdb assertions
444
- * report NONE with an explanatory detail.
448
+ * report NONE unless skipDiskOnlyDuckdb is set.
449
+ * @param {Record<string, Array<Object>>} [opts.warehouseRows] - Loaded
450
+ * warehouse rows keyed by table name.
451
+ * @param {Record<string, Object>} [opts.warehouseSpecs] - Resolved warehouse
452
+ * metric specs keyed by table name.
453
+ * @param {string|number} [opts.datasetStart] - Dataset window start for
454
+ * in-window warehouse stats comparisons.
455
+ * @param {string|number} [opts.datasetEnd] - Dataset window end for in-window
456
+ * warehouse stats comparisons.
457
+ * @param {boolean} [opts.skipDiskOnlyDuckdb] - Mark duckdb assertions skipped
458
+ * instead of NONE when no SQL executor is available.
459
+ * @param {(message: string) => void} [opts.onWarning] - Warning sink for
460
+ * skipped disk-only assertions.
445
461
  * @returns {Promise<Array<{ id: string, hook: string, archetype: string, verdict: string, assertions: Array<{ name: string, verdict: string, observed: number|null, detail: string }> }>>}
446
462
  */
447
463
  export async function evaluateStories(stories, events, opts) {
448
- const { profiles, funnels, runSql, identityMap } = opts || {};
464
+ const {
465
+ profiles,
466
+ funnels,
467
+ runSql,
468
+ identityMap,
469
+ warehouseRows,
470
+ warehouseSpecs,
471
+ datasetStart,
472
+ datasetEnd,
473
+ skipDiskOnlyDuckdb,
474
+ onWarning,
475
+ } = opts || {};
449
476
  const v = validateStories(stories);
450
477
  if (!v.valid) {
451
478
  throw new Error(`evaluateStories: invalid stories:\n ${v.errors.join('\n ')}`);
@@ -460,10 +487,21 @@ export async function evaluateStories(stories, events, opts) {
460
487
  try {
461
488
  if (assertion.breakdown.type === 'duckdb') {
462
489
  if (!runSql) {
463
- results.push({ name, verdict: 'NONE', observed: null, detail: 'duckdb assertion requires disk mode (no SQL executor available)' });
490
+ if (skipDiskOnlyDuckdb) {
491
+ onWarning?.(`[dungeon-master] ${name}: duckdb assertions only run in disk mode (scripts/verify-stories.mjs) — skipped in-memory.`);
492
+ results.push({ name, verdict: 'SKIPPED', observed: null, detail: 'duckdb assertion requires disk mode (skipped in-memory)' });
493
+ } else {
494
+ results.push({ name, verdict: 'NONE', observed: null, detail: 'duckdb assertion requires disk mode (no SQL executor available)' });
495
+ }
464
496
  continue;
465
497
  }
466
498
  rows = await runSql(assertion.breakdown.sql);
499
+ } else if (assertion.breakdown.type === 'warehouse') {
500
+ rows = resolveWarehouseRows(assertion.breakdown.table, warehouseRows, warehouseSpecs);
501
+ } else if (assertion.breakdown.type === 'warehouse-stats') {
502
+ const spec = resolveWarehouseSpec(assertion.breakdown.table, warehouseSpecs);
503
+ const tableRows = resolveWarehouseRows(assertion.breakdown.table, warehouseRows, warehouseSpecs);
504
+ rows = [computeWarehouseStats(tableRows, spec, computeWarehouseSourceRows(events, spec), { datasetStart, datasetEnd })];
467
505
  } else {
468
506
  const bArgs = applyFunnelDefaults(assertion.breakdown, funnels, profiles);
469
507
  if (identityMap && bArgs.identityMap === undefined) bArgs.identityMap = identityMap;
@@ -476,8 +514,33 @@ export async function evaluateStories(stories, events, opts) {
476
514
  const res = evaluateAssertion(rows, assertion, { events, profiles });
477
515
  results.push({ name, ...res });
478
516
  }
479
- const worst = results.reduce((w, r) => VERDICT_RANK[r.verdict] < VERDICT_RANK[w] ? r.verdict : w, 'NAILED');
517
+ const counted = results.filter((result) => result.verdict !== 'SKIPPED');
518
+ const worst = counted.length
519
+ ? counted.reduce((w, r) => VERDICT_RANK[r.verdict] < VERDICT_RANK[w] ? r.verdict : w, 'NAILED')
520
+ : 'SKIPPED';
480
521
  out.push({ id: story.id, hook: story.hook, archetype: story.archetype, verdict: worst, assertions: results });
481
522
  }
482
523
  return out;
483
524
  }
525
+
526
+ function resolveWarehouseSpec(table, warehouseSpecs) {
527
+ const spec = warehouseSpecs?.[table];
528
+ if (!spec) throw new Error(`warehouse table "${table}" is not available`);
529
+ return spec;
530
+ }
531
+
532
+ function resolveWarehouseRows(table, warehouseRows, warehouseSpecs) {
533
+ const spec = resolveWarehouseSpec(table, warehouseSpecs);
534
+ const rows = warehouseRows?.[table];
535
+ if (!Array.isArray(rows)) throw new Error(`warehouse rows for table "${table}" are not available`);
536
+ return rows.map((row) => withWarehouseTimestamp(row, spec));
537
+ }
538
+
539
+ function withWarehouseTimestamp(row, spec) {
540
+ if (Number.isFinite(row?.__t)) return row;
541
+ const raw = row?.[spec.timeColumn];
542
+ const parsed = typeof raw === 'string'
543
+ ? Date.parse(/T/.test(raw) ? raw : `${raw}T00:00:00Z`)
544
+ : NaN;
545
+ return { ...row, __t: Number.isFinite(parsed) ? parsed / 1000 : null };
546
+ }