@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/README.md +73 -10
- package/index.ts +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/src/env.js +4 -6
- package/lib/src/object.d.ts +208 -7
- package/lib/src/object.js +277 -37
- package/lib/src/string.js +1 -4
- package/package.json +1 -1
- package/src/env.ts +3 -4
- package/src/object.ts +360 -29
- package/src/string.ts +0 -4
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
|
|
6
|
-
*
|
|
7
|
-
* coerces a date/ISO string/epoch
|
|
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,
|
|
10
|
-
*
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
if (
|
|
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)
|
|
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())
|
|
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)
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
1042
|
-
if (
|
|
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,
|