@dbx-tools/shared-core 0.6.60 → 0.6.62

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/src/object.ts CHANGED
@@ -2,12 +2,16 @@
2
2
  * Dependency-free object + iterable utilities.
3
3
  *
4
4
  * Value guards / coercions / shape types: {@link isRecord} narrows parsed JSON
5
- * to a record, {@link toBoolean} coerces loose truthy/falsy values, {@link
6
- * toDuration} parses `1h30m` / `2 milliseconds` to millis, {@link toDate}
7
- * coerces a date/ISO string/epoch number/relative duration, {@link optional}
5
+ * to a record, {@link toNumber} coerces a hand-typed numeral, {@link toBoolean}
6
+ * coerces loose truthy/falsy values, {@link toDuration} parses `1h30m` /
7
+ * `2 milliseconds` to millis, {@link toDate} coerces a date/ISO string/epoch
8
+ * number/relative duration (`toDate` and `toDuration` fall back to each other, so
9
+ * either accepts both readings), {@link optional}
8
10
  * spreads a field only when it is present, {@link deepEqual} compares
9
- * structurally, and {@link NameLike}/{@link NonFunctionKeys} describe object
10
- * shapes.
11
+ * structurally, {@link isSerializableValue} rejects anything a JSON round trip
12
+ * would lose or coerce, {@link toStableKey} canonicalizes a value so an identity
13
+ * can be derived from it, and {@link NameLike}/{@link NonFunctionKeys} describe
14
+ * object shapes.
11
15
  *
12
16
  * Iterable helpers: {@link generator} flattens mixed arguments; {@link sequence}
13
17
  * wraps source(s) in a lazy, `Array`-compatible {@link Sequence}. Every
@@ -815,6 +819,155 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
815
819
  return typeof value === "object" && value !== null && !Array.isArray(value);
816
820
  }
817
821
 
822
+ /** A JSON scalar. `undefined` is deliberately absent - `JSON.stringify` drops it. */
823
+ export type SerializablePrimitive = string | number | boolean | null;
824
+
825
+ /**
826
+ * Any value that survives a `JSON.stringify`/`JSON.parse` round trip unchanged.
827
+ *
828
+ * Use it instead of `unknown` on a boundary that will serialize its input - a
829
+ * message payload, a cache entry, a config blob written to disk - so a `Date`,
830
+ * a `Map`, or a class instance is a compile error at the call site rather than a
831
+ * receiver quietly getting a string or `{}`. {@link isSerializableValue} is the
832
+ * runtime half, for input the compiler cannot vouch for.
833
+ */
834
+ export type SerializableValue =
835
+ SerializablePrimitive | SerializableValue[] | { [key: string]: SerializableValue };
836
+
837
+ /**
838
+ * True when `value` survives a JSON round trip with no loss and no coercion.
839
+ *
840
+ * Stricter than "`JSON.stringify` did not throw", because that succeeds while
841
+ * silently CHANGING the value: a `Date` becomes a string, `NaN` and `Infinity`
842
+ * become `null`, a `Map` becomes `{}`, and `undefined` disappears from an object
843
+ * or turns into `null` inside an array. Each of those reaches the far side as
844
+ * something other than what was sent, so all of them are rejected here.
845
+ *
846
+ * Rejected: non-finite numbers, `undefined`, functions, symbols, bigints, class
847
+ * instances and anything else with a prototype other than `Object.prototype` or
848
+ * `null` (`Date`, `Map`, `Set`, `RegExp`, `Buffer`), and any object graph
849
+ * containing a cycle. Accepted: strings, booleans, `null`, finite numbers, plain
850
+ * objects, arrays, and nestings of those.
851
+ *
852
+ * Narrows to {@link SerializableValue} rather than asserting, so it also serves
853
+ * as the validator for untrusted input - a request body, a decoded notification
854
+ * payload - where the answer should be a 400 and not a throw. Never throws.
855
+ *
856
+ * Distinct from {@link deepEqual}'s notion of comparable: that one HANDLES
857
+ * `Date`/`Map`/`Set` structurally, while this one rejects them precisely because
858
+ * JSON cannot carry them.
859
+ *
860
+ * @param ancestors Cycle-detection set for the recursive walk. Internal; callers
861
+ * pass one value.
862
+ *
863
+ * @example
864
+ * isSerializableValue({ a: [1, "x", null] }); // true
865
+ * isSerializableValue({ at: new Date() }); // false - would become a string
866
+ * isSerializableValue(Number.NaN); // false - would become null
867
+ */
868
+ export function isSerializableValue(
869
+ value: unknown,
870
+ ancestors: Set<object> = new Set(),
871
+ ): value is SerializableValue {
872
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
873
+ if (typeof value === "number") return Number.isFinite(value);
874
+ if (typeof value !== "object") return false;
875
+
876
+ const prototype = Object.getPrototypeOf(value);
877
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return false;
878
+ if (ancestors.has(value)) return false;
879
+
880
+ ancestors.add(value);
881
+ const valid = Array.isArray(value)
882
+ ? value.every((entry) => isSerializableValue(entry, ancestors))
883
+ : Object.values(value).every((entry) => isSerializableValue(entry, ancestors));
884
+ ancestors.delete(value);
885
+ return valid;
886
+ }
887
+
888
+ /**
889
+ * Canonical string for a structured value, for deriving a STABLE IDENTITY from
890
+ * it - an advisory-lock id, a channel name, a cache key.
891
+ *
892
+ * The guarantee is two-way, which is what makes it safe to hash: values that
893
+ * should share an identity produce the same string (object key order does not
894
+ * matter), and values that should not are never conflated. Every token carries
895
+ * its type, so `1` and `"1"` differ; a string carries its length, so
896
+ * `["a", "bc"]` and `["ab", "c"]` differ; arrays keep order while object keys
897
+ * are sorted.
898
+ *
899
+ * `JSON.stringify` cannot do this job - key order leaks in, `undefined` vanishes,
900
+ * `1` and `"1"` collide after quoting is stripped, and a cycle throws a
901
+ * `TypeError` naming neither the value nor the caller's intent.
902
+ *
903
+ * Deliberately strict where a silent answer would be a WRONG identity rather
904
+ * than a missing one, since two callers disagreeing about a lock or a channel is
905
+ * invisible until it corrupts something. Throws `TypeError` on a cycle, on a
906
+ * non-finite number (`NaN` is not equal to itself, so it cannot have a stable
907
+ * identity), and on a `function` or `symbol` (no meaningful value identity).
908
+ * `undefined` and `null` are accepted as distinct tokens.
909
+ *
910
+ * `Date` is canonicalized by instant, unlike the hash canonicalizer in
911
+ * `./hash.ts`, which folds every `Date` onto one token. Prefer this function when
912
+ * distinctness is a correctness requirement; prefer `hash.fnvHash` when a short,
913
+ * collision-tolerant digest is enough.
914
+ *
915
+ * @param value - The value to canonicalize.
916
+ * @param seen - Cycle-detection set for the recursive walk. Internal; callers
917
+ * pass one value.
918
+ *
919
+ * @example
920
+ * toStableKey({ a: 1, b: 2 }) === toStableKey({ b: 2, a: 1 }); // true
921
+ * toStableKey(1) !== toStableKey("1"); // true
922
+ */
923
+ export function toStableKey(value: unknown, seen: Set<object> = new Set()): string {
924
+ if (value === null) return "null";
925
+
926
+ switch (typeof value) {
927
+ case "string":
928
+ // Length-prefixed so concatenated neighbours cannot be re-split
929
+ // differently: `["a","bc"]` and `["ab","c"]` must not agree.
930
+ return `string:${value.length}:${value}`;
931
+ case "boolean":
932
+ return `boolean:${value}`;
933
+ case "bigint":
934
+ return `bigint:${value}`;
935
+ case "number":
936
+ if (!Number.isFinite(value)) throw new TypeError("Stable keys require finite numbers");
937
+ // `-0 === 0` but they stringify differently, so pick one spelling.
938
+ return `number:${Object.is(value, -0) ? "-0" : value}`;
939
+ case "undefined":
940
+ return "undefined";
941
+ case "object": {
942
+ if (seen.has(value)) throw new TypeError("Stable keys cannot contain cycles");
943
+ seen.add(value);
944
+ try {
945
+ if (value instanceof Date) return `date:${value.toISOString()}`;
946
+ if (Array.isArray(value)) {
947
+ return `array:[${value.map((item) => toStableKey(item, seen)).join(",")}]`;
948
+ }
949
+ if (value instanceof Set) {
950
+ // Sorted by canonical form, so insertion order does not leak.
951
+ return `set:[${[...value]
952
+ .map((item) => toStableKey(item, seen))
953
+ .sort()
954
+ .join(",")}]`;
955
+ }
956
+ const entries: Iterable<[unknown, unknown]> =
957
+ value instanceof Map ? value : Object.entries(value);
958
+ return `${value instanceof Map ? "map" : "object"}:{${[...entries]
959
+ .map(([key, item]) => `${toStableKey(key, seen)}=${toStableKey(item, seen)}`)
960
+ .sort()
961
+ .join(",")}}`;
962
+ } finally {
963
+ seen.delete(value);
964
+ }
965
+ }
966
+ default:
967
+ throw new TypeError(`Unsupported stable key type: ${typeof value}`);
968
+ }
969
+ }
970
+
818
971
  /**
819
972
  * `{ [key]: value }` when `value` is present, otherwise `undefined` - so an
820
973
  * absent optional field stays ABSENT when spread, rather than becoming an
@@ -837,6 +990,97 @@ export function optional<K extends string, V>(
837
990
  return value === null || value === undefined ? undefined : ({ [key]: value } as Record<K, V>);
838
991
  }
839
992
 
993
+ /**
994
+ * Options for {@link toNumber}.
995
+ *
996
+ * Both switches turn OFF a leniency that is helpful for a hand-typed setting but
997
+ * wrong when the string's other characters carry meaning. {@link toDate} and
998
+ * {@link toDuration} disable both, since a space inside `2026 08 02` is a field
999
+ * separator and a percent has no epoch or millisecond reading.
1000
+ */
1001
+ export interface ToNumberOptions {
1002
+ /**
1003
+ * Whether internal digit-group separators are stripped, so `"1,000"` and
1004
+ * `"1 000"` read as `1000`. Defaults to `true`.
1005
+ *
1006
+ * Placement is not validated when enabled, so `"1,00,0"` also reads as `1000`.
1007
+ * Disable it when whitespace or a comma delimits FIELDS rather than grouping
1008
+ * digits, because stripping them silently fuses those fields into one number.
1009
+ */
1010
+ separators?: boolean;
1011
+ /**
1012
+ * Whether a trailing percent sign divides the result by `100`, so `"25%"` reads
1013
+ * as `0.25`. Defaults to `true`.
1014
+ *
1015
+ * Disable it where a percentage has no meaning, so `"25%"` is a miss rather
1016
+ * than a number two orders of magnitude away from what the text says.
1017
+ */
1018
+ percent?: boolean;
1019
+ }
1020
+
1021
+ /**
1022
+ * Coerce a loose numeric value to a real, FINITE `number`, or `undefined` when it
1023
+ * carries no numeric meaning.
1024
+ *
1025
+ * The one place a hand-typed number is interpreted, alongside {@link toBoolean},
1026
+ * {@link toDate}, and {@link toDuration}. Reach for it instead of `Number(x)` or a
1027
+ * hand-rolled numeric regex: bare `Number` maps `""`, `null`, `[]`, and
1028
+ * whitespace to `0` and anything else to `NaN`, so a caller has to re-check the
1029
+ * result every time, and ad hoc regexes tend to drift in what they accept.
1030
+ *
1031
+ * Accepts: a finite `number`; a `bigint`; or a decimal string with an optional
1032
+ * leading sign, leading/trailing whitespace, whitespace after the sign,
1033
+ * digit-group separators (`"1,000"`, `"1 000"`), a bare fraction (`".5"`), a
1034
+ * trailing point (`"1."`), scientific notation (`"1e3"`), and an optional
1035
+ * trailing percent sign (`"25%"`, `"1.5 %"`). A trailing `%` divides the parsed
1036
+ * value by `100`, so `"25%"` becomes `0.25`.
1037
+ *
1038
+ * Separators are stripped without validating their placement, so `"1,00,0"` reads
1039
+ * as `1000`; this is a coercion for hand-typed configuration, not a locale-aware
1040
+ * validator. Anything else, including `NaN`, `Infinity`, an empty or
1041
+ * whitespace-only string, `null`, `undefined`, a boolean, multiple signs,
1042
+ * malformed exponents, misplaced percent signs, or `"12px"`, returns `undefined`.
1043
+ *
1044
+ * Returns `undefined` rather than throwing, matching the other coercions, so it
1045
+ * composes naturally with `??` fallbacks.
1046
+ *
1047
+ * @example
1048
+ * toNumber("1,000"); // 1000
1049
+ * toNumber(" -2.5 "); // -2.5
1050
+ * toNumber("1e3"); // 1000
1051
+ * toNumber("12.5 %"); // 0.125
1052
+ * toNumber(""); // undefined (Number("") would be 0)
1053
+ * toNumber("12px"); // undefined
1054
+ * toNumber("1 000", { separators: false }); // undefined
1055
+ */
1056
+ export function toNumber(value: unknown, options: ToNumberOptions = {}): number | undefined {
1057
+ if (typeof value === "number") {
1058
+ return Number.isFinite(value) ? value : undefined;
1059
+ } else if (typeof value === "bigint") {
1060
+ return toNumber(Number(value), options);
1061
+ } else if (typeof value === "string") {
1062
+ // Strip surrounding whitespace, whitespace after a leading sign, digit-group
1063
+ // separators (both `,` and the space in `"1 000"`), leaving a bare numeral for
1064
+ // the shape test below to accept or reject.
1065
+ const text = value.replace(
1066
+ options.separators === false ? /(^\s+|\s+$)/g : /(^\s+|\s+$|(?<=^[+-])\s+|(?<=\d)\s+|,)/g,
1067
+ "",
1068
+ );
1069
+ if (text) {
1070
+ const match = text.match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(%)?$/i);
1071
+ if (match && !(match[2] && options.percent === false)) {
1072
+ const number = Number(match[1]);
1073
+ return toNumber(match[2] ? number / 100 : number, options);
1074
+ }
1075
+ }
1076
+ } else {
1077
+ // A boxed `Number`, or anything with a numeric `toString`. `String(value)`
1078
+ // routes it back through the string branch, which rejects what is not numeric.
1079
+ return toNumber(String(value), options);
1080
+ }
1081
+ return undefined;
1082
+ }
1083
+
840
1084
  /**
841
1085
  * Coerce a loose boolean-ish value to a real `boolean`, or `undefined`
842
1086
  * when it can't be interpreted. Recognizes `true`/`t`/`on`/`1`/`yes`/`y`
@@ -885,6 +1129,19 @@ export function toBoolean(value: unknown): boolean | undefined {
885
1129
  */
886
1130
  const SECONDS_CEILING = 1e11;
887
1131
 
1132
+ /**
1133
+ * A number for {@link toDate} and {@link toDuration}, read with none of
1134
+ * {@link toNumber}'s string leniencies.
1135
+ *
1136
+ * Both leniencies are actively harmful here. A space or comma separates FIELDS in
1137
+ * a date (`2026 08 02` would fuse into the epoch `20260802`), and a percentage has
1138
+ * no reading as an instant or a length of time, so `25%` must be a miss rather
1139
+ * than `250ms`.
1140
+ */
1141
+ function toBareNumber(value: unknown): number | undefined {
1142
+ return toNumber(value, { separators: false, percent: false });
1143
+ }
1144
+
888
1145
  /**
889
1146
  * Milliseconds per unit, keyed by SINGULAR alias. {@link toDuration} retries a
890
1147
  * lookup without a trailing `s`, so every plural spelling is covered without
@@ -912,6 +1169,47 @@ const DURATION_UNIT_MS: ReadonlyMap<string, number> = new Map(
912
1169
  /** One `<amount><unit>` term inside a duration string. */
913
1170
  const DURATION_TERM = /([+-]?)(\d+(?:\.\d+)?)\s*([a-z]+)/g;
914
1171
 
1172
+ /**
1173
+ * Options for {@link toDate}.
1174
+ *
1175
+ * The mirror of {@link ToDurationOptions}: each function can fall back to the
1176
+ * other, so each has one switch turning that fallback off.
1177
+ */
1178
+ export interface ToDateOptions {
1179
+ /**
1180
+ * Whether a {@link toDuration} expression is read as an instant relative to
1181
+ * now, so `-7d` and `7 days ago` become `now - 7 days`. Defaults to `true`.
1182
+ *
1183
+ * Set `false` when the value must be a real date and a relative expression
1184
+ * should be a miss - a stored timestamp, a user-supplied `Date` header, an
1185
+ * `expires_at` field - since a duration silently resolving against the current
1186
+ * clock makes the same input mean something different on every call. Also what
1187
+ * {@link toDuration} passes when it recurses, so the two cannot bounce a value
1188
+ * between them forever.
1189
+ */
1190
+ parseDuration?: boolean;
1191
+ }
1192
+
1193
+ /**
1194
+ * Options for {@link toDuration}.
1195
+ *
1196
+ * The mirror of {@link ToDateOptions}: each function can fall back to the other,
1197
+ * so each has one switch turning that fallback off.
1198
+ */
1199
+ export interface ToDurationOptions {
1200
+ /**
1201
+ * Whether a {@link toDate} value is read as the signed offset from now, so
1202
+ * `2026-08-02` becomes however long until (or since) that instant. Defaults to
1203
+ * `true`.
1204
+ *
1205
+ * Set `false` when only a length of time is meaningful - a timeout, a poll
1206
+ * interval, a cache TTL - because a date would otherwise yield a plausible but
1207
+ * wrong number that also drifts with the clock. Also what {@link toDate} passes
1208
+ * when it recurses.
1209
+ */
1210
+ parseDate?: boolean;
1211
+ }
1212
+
915
1213
  /**
916
1214
  * Coerce a loose duration to MILLISECONDS, or `undefined` when it can't be
917
1215
  * interpreted.
@@ -933,19 +1231,29 @@ const DURATION_TERM = /([+-]?)(\d+(?:\.\d+)?)\s*([a-z]+)/g;
933
1231
  * returning `1` silently would be worse than returning nothing - which is also
934
1232
  * what keeps {@link toDate} from mistaking `1 Jan 2026` for a duration.
935
1233
  *
1234
+ * A value that is not a duration at all is offered to {@link toDate} and, when it
1235
+ * IS a date, read as the signed offset from now (`date - now`), which makes the
1236
+ * two functions inverses: a past instant is negative, a future one positive.
1237
+ * `options.parseDate: false` turns that off when only a length of time makes
1238
+ * sense - see {@link ToDurationOptions}.
1239
+ *
936
1240
  * @example
937
1241
  * toDuration("30s"); // 30_000
938
1242
  * toDuration("1 hour 30 minutes"); // 5_400_000
939
1243
  * toDuration("-7 days"); // -604_800_000
940
1244
  * toDuration("2 weeks ago"); // -1_209_600_000
1245
+ * toDuration("2026-08-02"); // ms from now to that instant
1246
+ * toDuration("2026-08-02", { parseDate: false }); // undefined
941
1247
  * toDuration("soon"); // undefined
942
1248
  */
943
- export function toDuration(value: unknown): number | undefined {
944
- if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
945
- if (typeof value !== "string") return undefined;
946
- // A unitless string means the same as the unitless number: milliseconds.
947
- if (/^[+-]?\d+(\.\d+)?$/.test(value.trim())) return toDuration(Number(value.trim()));
948
-
1249
+ export function toDuration(value: unknown, options: ToDurationOptions = {}): number | undefined {
1250
+ // A bare number is already milliseconds, the same reading `toDate` gives an
1251
+ // epoch value, so this must run before any date interpretation.
1252
+ const num = toBareNumber(value);
1253
+ if (num !== undefined) return num;
1254
+ if (typeof value !== "string") {
1255
+ return options.parseDate === false ? undefined : dateAsDuration(value);
1256
+ }
949
1257
  let text = value
950
1258
  .toLowerCase()
951
1259
  .replaceAll(/[,_]/g, "")
@@ -960,25 +1268,44 @@ export function toDuration(value: unknown): number | undefined {
960
1268
  if (!text) return undefined;
961
1269
 
962
1270
  const terms = [...text.matchAll(DURATION_TERM)];
963
- if (terms.length === 0) return undefined;
1271
+ if (terms.length === 0) {
1272
+ return options.parseDate === false ? undefined : dateAsDuration(value);
1273
+ }
964
1274
 
965
1275
  // Everything outside the matched terms must be separators only; otherwise the
966
1276
  // string is something else that merely CONTAINS a duration-shaped fragment.
967
1277
  let leftover = text;
968
1278
  for (const term of terms) leftover = leftover.replace(term[0], " ");
969
- if (leftover.trim()) return undefined;
1279
+ if (leftover.trim()) {
1280
+ // A duration-shaped fragment inside other text is usually a DATE - `"2026-08-02"`
1281
+ // matches two terms and leaves separators behind.
1282
+ return options.parseDate === false ? undefined : dateAsDuration(value);
1283
+ }
970
1284
 
971
1285
  let total = 0;
972
1286
  let sign = 1;
973
1287
  for (const [, explicitSign, amount, unit] of terms) {
974
1288
  if (explicitSign) sign = explicitSign === "-" ? -1 : 1;
975
1289
  const unitMs = DURATION_UNIT_MS.get(unit) ?? DURATION_UNIT_MS.get(unit.replace(/s$/, ""));
976
- if (unitMs === undefined) return undefined;
1290
+ if (unitMs === undefined) {
1291
+ return options.parseDate === false ? undefined : dateAsDuration(value);
1292
+ }
977
1293
  total += sign * Number(amount) * unitMs;
978
1294
  }
979
1295
  return negate ? -total : total;
980
1296
  }
981
1297
 
1298
+ /**
1299
+ * A date read as the signed offset from now (`date - now`), which is what makes
1300
+ * {@link toDuration} and {@link toDate} inverses of each other. Recurses with
1301
+ * duration parsing DISABLED so the two cannot hand a value back and forth
1302
+ * forever.
1303
+ */
1304
+ function dateAsDuration(value: unknown): number | undefined {
1305
+ const date = toDate(value, { parseDuration: false });
1306
+ return date === undefined ? undefined : date.getTime() - Date.now();
1307
+ }
1308
+
982
1309
  /**
983
1310
  * Coerce a loose date-ish value to a real `Date`, or `undefined` when it can't be
984
1311
  * interpreted. Accepts, in this order:
@@ -998,6 +1325,10 @@ export function toDuration(value: unknown): number | undefined {
998
1325
  * 1.7 billion years out. Numeric strings are therefore routed to the epoch path,
999
1326
  * never to `Date.parse`.
1000
1327
  *
1328
+ * The duration fallback runs LAST, after `Date.parse`, so a real date is never
1329
+ * mistaken for an offset. `options.parseDuration: false` removes it entirely when
1330
+ * the value must be an absolute instant - see {@link ToDateOptions}.
1331
+ *
1001
1332
  * Like {@link toBoolean} this NEVER throws and returns `undefined` for anything
1002
1333
  * uninterpretable, so a caller decides whether a bad value is fatal, a warning,
1003
1334
  * or a fallback.
@@ -1008,9 +1339,10 @@ export function toDuration(value: unknown): number | undefined {
1008
1339
  * toDate("1785697899"); // seconds -> 2026-08-02T...
1009
1340
  * toDate(1785697899000); // millis -> the same instant
1010
1341
  * toDate("30 days ago"); // now - 30d
1342
+ * toDate("30 days ago", { parseDuration: false }); // undefined
1011
1343
  * toDate("nope"); // undefined
1012
1344
  */
1013
- export function toDate(value: unknown): Date | undefined {
1345
+ export function toDate(value: unknown, options: ToDateOptions = {}): Date | undefined {
1014
1346
  if (value instanceof Date) {
1015
1347
  return Number.isNaN(value.getTime()) ? undefined : value;
1016
1348
  }
@@ -1023,11 +1355,14 @@ export function toDate(value: unknown): Date | undefined {
1023
1355
  const text = value.trim().toLowerCase();
1024
1356
  if (text === "now" || text === "today") return new Date();
1025
1357
 
1026
- const offsetMs = toDuration(value);
1027
- if (offsetMs !== undefined) return fromMs(Date.now() + offsetMs);
1028
-
1029
1358
  const parsed = Date.parse(value.trim());
1030
- return Number.isNaN(parsed) ? undefined : new Date(parsed);
1359
+ if (!Number.isNaN(parsed)) return new Date(parsed);
1360
+
1361
+ if (options.parseDuration === false) return undefined;
1362
+ // Recurses with date parsing DISABLED so the two cannot hand a value back and
1363
+ // forth forever.
1364
+ const offsetMs = toDuration(value, { parseDate: false });
1365
+ return offsetMs === undefined ? undefined : fromMs(Date.now() + offsetMs);
1031
1366
  }
1032
1367
 
1033
1368
  /** A `Date` for epoch `ms`, or `undefined` when it is out of representable range. */
@@ -1036,17 +1371,13 @@ function fromMs(ms: number): Date | undefined {
1036
1371
  return Number.isNaN(date.getTime()) ? undefined : date;
1037
1372
  }
1038
1373
 
1039
- /** Epoch milliseconds for a numeric value/string, or `undefined` when not one. */
1374
+ /**
1375
+ * Epoch milliseconds for a numeric value/string, or `undefined` when not one,
1376
+ * applying the seconds-vs-millis inference at {@link SECONDS_CEILING}.
1377
+ */
1040
1378
  function toEpochMs(value: unknown): number | undefined {
1041
- let numeric: number;
1042
- if (typeof value === "number") {
1043
- numeric = value;
1044
- } else if (typeof value === "string" && /^[+-]?\d+(\.\d+)?$/.test(value.trim())) {
1045
- numeric = Number(value.trim());
1046
- } else {
1047
- return undefined;
1048
- }
1049
- if (!Number.isFinite(numeric)) return undefined;
1379
+ const numeric = toBareNumber(value);
1380
+ if (numeric === undefined) return undefined;
1050
1381
  return Math.abs(numeric) < SECONDS_CEILING ? numeric * 1000 : numeric;
1051
1382
  }
1052
1383
 
package/src/string.ts CHANGED
@@ -1,7 +1,3 @@
1
- // GENERATED by projen synth (rs-packages) - DO NOT EDIT.
2
- // Regenerated from rs-packages/shared/core/src/string.ts.
3
- // Hand edits are overwritten on the next watch; this file is read-only.
4
-
5
1
  /**
6
2
  * Browser-safe string toolkit: tokenization, identifier / slug
7
3
  * generation (with hash-suffix collision resistance), HTML escaping,