@trackunit/shared-utils 1.16.4 → 1.16.5

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/index.cjs.js CHANGED
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ var esToolkit = require('es-toolkit');
3
4
  var zod = require('zod');
5
+ var superjson = require('superjson');
4
6
  var uuid = require('uuid');
5
7
 
6
8
  /**
@@ -549,7 +551,7 @@ const insertToUUID = (arr, position, value) => [
549
551
  * @param input A string.
550
552
  * @param maxLength The maximum length of the string that it should pad for.
551
553
  */
552
- const zeroPadToUUIDLength = (input, maxLength) => input.length < maxLength ? zeroPadToUUIDLength("0" + input, maxLength) : input;
554
+ const zeroPadToUUIDLength = (input, maxLength) => input.length < maxLength ? zeroPadToUUIDLength(`0${input}`, maxLength) : input;
553
555
  /**
554
556
  * A helper function that converts any value into a UUID.
555
557
  *
@@ -705,6 +707,424 @@ const fetchImageAsBase64 = async (url) => {
705
707
  });
706
708
  };
707
709
 
710
+ /**
711
+ * Runs a sequential migration pipeline on the provided data, applying
712
+ * each migration whose version is in the range (fromVersion, toVersion].
713
+ *
714
+ * Throws if the filtered migrations have non-contiguous versions (gaps).
715
+ */
716
+ const runMigrations = ({ data, fromVersion, toVersion, migrations, }) => {
717
+ if (toVersion <= fromVersion) {
718
+ return data;
719
+ }
720
+ const applicable = migrations
721
+ .toSorted((a, b) => a.version - b.version)
722
+ .filter(m => m.version > fromVersion && m.version <= toVersion);
723
+ if (applicable.length === 0) {
724
+ return data;
725
+ }
726
+ for (let i = 1; i < applicable.length; i++) {
727
+ const prev = applicable[i - 1];
728
+ const curr = applicable[i];
729
+ if (prev && curr && curr.version !== prev.version + 1) {
730
+ throw new Error(`Migration gap detected: found version ${prev.version} and ${curr.version} but no migration for version ${prev.version + 1}.`);
731
+ }
732
+ }
733
+ return applicable.reduce((acc, migration) => migration.migrate(acc), data);
734
+ };
735
+
736
+ /**
737
+ * Recursively builds a salvaging schema from a ZodObject by injecting `.catch(defaultValue[key])`
738
+ * for each field. This means invalid field values fall back to defaultState values rather than
739
+ * failing the entire parse.
740
+ *
741
+ * Unwraps ZodOptional, ZodNullable, and ZodDefault to reach the inner schema.
742
+ * ZodCatch is intentionally NOT unwrapped — consumer-defined catches take precedence.
743
+ * Non-ZodObject schemas (unions, discriminated unions, refinements, etc.) are returned unchanged
744
+ * so they behave as atomic units.
745
+ */
746
+ function buildSalvagingSchema(schema, defaultValue) {
747
+ if (schema instanceof zod.z.ZodOptional) {
748
+ return buildSalvagingSchema(schema.unwrap(), defaultValue).optional();
749
+ }
750
+ if (schema instanceof zod.z.ZodNullable) {
751
+ return buildSalvagingSchema(schema.unwrap(), defaultValue).nullable();
752
+ }
753
+ if (schema instanceof zod.z.ZodDefault) {
754
+ return buildSalvagingSchema(schema._def.innerType, defaultValue);
755
+ }
756
+ if (!(schema instanceof zod.z.ZodObject)) {
757
+ return schema;
758
+ }
759
+ const defaultRecordResult = zod.z.record(zod.z.string(), zod.z.unknown()).safeParse(defaultValue);
760
+ if (!defaultRecordResult.success) {
761
+ return schema;
762
+ }
763
+ const defaultRecord = defaultRecordResult.data;
764
+ const salvagingShape = {};
765
+ for (const key of Object.keys(schema.shape)) {
766
+ const fieldSchema = schema.shape[key];
767
+ if (fieldSchema === undefined)
768
+ continue;
769
+ const fieldDefault = defaultRecord[key];
770
+ salvagingShape[key] = buildSalvagingSchema(fieldSchema, fieldDefault).catch(fieldDefault);
771
+ }
772
+ return zod.z.object(salvagingShape);
773
+ }
774
+ /**
775
+ * Attempts to salvage partial state from raw data when full schema validation has failed.
776
+ *
777
+ * For ZodObject schemas, rebuilds the schema with per-field `.catch(defaultState[key])` fallbacks
778
+ * and reparses. Valid field values from the stored data are kept; invalid fields fall back to their
779
+ * corresponding values in `defaultState`. Works recursively for nested ZodObject fields.
780
+ *
781
+ * Non-object schemas (discriminated unions, unions with `.refine()`, etc.) cannot be partially
782
+ * salvaged and return `null`.
783
+ *
784
+ * Consumer-defined `.catch()` wrappers take precedence over the injected salvage catches, since
785
+ * Zod processes catches inside-out.
786
+ *
787
+ * Note: when every field is invalid, the salvaged result will be deeply equal to `defaultState`.
788
+ * Callers should compare the result against `defaultState` to distinguish a genuine partial
789
+ * salvage from a total loss where no stored data survived.
790
+ *
791
+ * @template TState - The type of the stored state.
792
+ * @param schema - Zod schema for validation.
793
+ * @param rawData - The raw deserialized value that failed validation.
794
+ * @param defaultState - The authoritative fallback value; used as per-field catch defaults.
795
+ * @returns {TState | null} The salvaged state, or `null` if salvaging is not possible or also fails.
796
+ */
797
+ const salvageState = (schema, rawData, defaultState) => {
798
+ try {
799
+ const salvagingSchema = buildSalvagingSchema(schema, defaultState);
800
+ const intermediate = salvagingSchema.safeParse(rawData);
801
+ if (!intermediate.success) {
802
+ return null;
803
+ }
804
+ // Re-validate through the original schema to ensure correctness and get the proper TState type.
805
+ // By construction this should always succeed: each field value is either the valid stored value
806
+ // or defaultState[key], both of which satisfy the original schema.
807
+ const result = schema.safeParse(intermediate.data);
808
+ return result.success ? result.data : null;
809
+ }
810
+ catch {
811
+ return null;
812
+ }
813
+ };
814
+
815
+ /**
816
+ * Internal envelope used to tag superjson-serialized data in web storage.
817
+ *
818
+ * `__serializer` is a **reserved internal key** — consumer state objects must
819
+ * not include it as a top-level key. The double-underscore prefix is a
820
+ * deliberate signal that this is a private implementation detail.
821
+ *
822
+ * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
823
+ * detected in the value being written.
824
+ */
825
+ const taggedSuperjsonEnvelopeSchema = zod.z.object({
826
+ __serializer: zod.z.literal("superjson"),
827
+ json: zod.z.custom(),
828
+ meta: zod.z.custom().optional(),
829
+ });
830
+ const storageSerializer = {
831
+ serialize: (value) => {
832
+ const serialized = superjson.serialize(value);
833
+ return JSON.stringify({ __serializer: "superjson", ...serialized });
834
+ },
835
+ deserialize: (value) => {
836
+ const parsed = JSON.parse(value);
837
+ const result = taggedSuperjsonEnvelopeSchema.safeParse(parsed);
838
+ if (result.success) {
839
+ return superjson.deserialize({ json: result.data.json, meta: result.data.meta });
840
+ }
841
+ return parsed;
842
+ },
843
+ };
844
+
845
+ /**
846
+ * Internal envelope that pairs stored data with a schema version number.
847
+ *
848
+ * `__version` and `__data` are **reserved internal keys** — consumer state
849
+ * objects must not include them as top-level keys. The double-underscore
850
+ * prefix is a deliberate signal that these are private implementation details.
851
+ *
852
+ * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
853
+ * detected in the value being written.
854
+ */
855
+ const storageVersionEnvelopeSchema = zod.z
856
+ .object({
857
+ __version: zod.z.number(),
858
+ __data: zod.z.unknown(),
859
+ })
860
+ .refine((x) => "__data" in x, {
861
+ message: "__data is required",
862
+ });
863
+ /** Wraps data and version into a versioned storage envelope. */
864
+ const createStorageVersionEnvelope = (data, version) => ({
865
+ __version: version,
866
+ __data: data,
867
+ });
868
+
869
+ /**
870
+ * Validates raw data against the schema, attempting partial salvage on failure.
871
+ *
872
+ * On success: returns the parsed data.
873
+ * On failure with a salvageable ZodObject schema where at least one stored field
874
+ * survived: returns the salvaged state and calls `onValidationSalvaged`.
875
+ * On failure where salvage produces a result identical to `defaultState` (i.e. every
876
+ * field was invalid and caught to its default): treats this as a total failure and
877
+ * calls `onValidationFailed` instead of `onValidationSalvaged`.
878
+ * On failure with no salvage possible: returns `defaultState` and calls `onValidationFailed`.
879
+ */
880
+ const validateOrSalvage = ({ rawValue, schema, defaultState, key, onValidationFailed, onValidationSalvaged, }) => {
881
+ const parseResult = schema.safeParse(rawValue);
882
+ if (parseResult.success) {
883
+ return parseResult.data;
884
+ }
885
+ const salvaged = salvageState(schema, rawValue, defaultState);
886
+ if (salvaged !== null && !esToolkit.isEqual(salvaged, defaultState)) {
887
+ // eslint-disable-next-line no-console
888
+ console.warn(`Partially invalid data in storage key "${key}" — salvaged valid fields, reset invalid fields to defaults.`, parseResult.error);
889
+ onValidationSalvaged?.(parseResult.error, salvaged);
890
+ return salvaged;
891
+ }
892
+ // eslint-disable-next-line no-console
893
+ console.error(`Failed to parse and validate the state from storage key "${key}". Returning default state.`, parseResult.error);
894
+ onValidationFailed?.(parseResult.error);
895
+ return defaultState;
896
+ };
897
+ /**
898
+ * Reads and deserializes a value from web storage, validating it against the provided schema.
899
+ * Falls back to defaultState if the key is missing or data is corrupt.
900
+ *
901
+ * On partial validation failure for ZodObject schemas, salvages valid fields rather than
902
+ * resetting the entire state to `defaultState`. See `salvageState` for salvage mechanics.
903
+ * If the salvaged result is deeply equal to `defaultState` (i.e. every field was invalid),
904
+ * the salvage is considered a total loss and `onValidationFailed` is called instead of
905
+ * `onValidationSalvaged`.
906
+ *
907
+ * Deserialization failures (unparseable/corrupt raw data) are routed through
908
+ * `onValidationFailed` so consumers are always notified when stored data is unusable.
909
+ *
910
+ * `legacyRawString` is a narrow, opt-in escape hatch for keys that are known to hold a
911
+ * plain-text value that was never JSON-encoded in the first place (e.g. a legacy username
912
+ * written via `localStorage.setItem` directly, with no `JSON.stringify`). When set, a raw
913
+ * value that fails JSON parsing is treated as the candidate string itself rather than a
914
+ * hard failure, and handed to schema validation (a permissive schema like `z.string()`
915
+ * recovers it; a schema expecting structured data still correctly rejects it). Leave this
916
+ * unset for every other key — enabling it globally would let a genuinely corrupted,
917
+ * truncated envelope be silently accepted as a valid string and re-persisted as such on the
918
+ * next write, permanently hiding the corruption. Scope it only to keys that are provably
919
+ * never written by anything other than a plain string.
920
+ *
921
+ * When `migration` is provided, detects versioned envelopes, runs the
922
+ * migration pipeline, and validates the migrated result. Non-versioned consumers are
923
+ * unaffected — the migration path is fully opt-in.
924
+ *
925
+ * @template TState - The type of the stored state.
926
+ * @param params - Storage, key, default state, Zod schema, optional migration config, and optional callbacks.
927
+ * @param params.storage - The web Storage instance.
928
+ * @param params.key - The storage key.
929
+ * @param params.defaultState - Fallback value when no stored data exists or data is corrupt.
930
+ * @param params.schema - Zod schema to validate the deserialized data.
931
+ * @param params.migration - Optional migration configuration (version, steps, fromKey).
932
+ * @param params.legacyRawString - Opt-in recovery for keys known to hold a plain-text value
933
+ * that was never JSON-encoded. See the note above the function for when (not) to use this.
934
+ * @param params.onValidationFailed - Called when validation fails and no salvage is possible, or when
935
+ * deserialization of the raw storage value fails entirely.
936
+ * @param params.onValidationSalvaged - Called when validation fails but at least one stored field
937
+ * was recovered (salvaged result differs from defaultState).
938
+ * @returns {TState} The validated or salvaged state, or defaultState.
939
+ */
940
+ const readFromStorage = ({ storage, key, defaultState, schema, migration, legacyRawString, onValidationFailed, onValidationSalvaged, }) => {
941
+ const version = migration?.version;
942
+ const steps = migration?.steps;
943
+ const fromKey = migration?.fromKey;
944
+ let raw = storage.getItem(key);
945
+ if (raw === null && fromKey !== undefined) {
946
+ raw = storage.getItem(fromKey);
947
+ }
948
+ if (raw === null) {
949
+ return defaultState;
950
+ }
951
+ let deserialized;
952
+ try {
953
+ deserialized = storageSerializer.deserialize(raw);
954
+ }
955
+ catch (deserializationError) {
956
+ if (legacyRawString) {
957
+ return validateOrSalvage({
958
+ rawValue: raw,
959
+ schema,
960
+ defaultState,
961
+ key,
962
+ onValidationFailed,
963
+ onValidationSalvaged,
964
+ });
965
+ }
966
+ // eslint-disable-next-line no-console
967
+ console.error(`Failed to deserialize storage key "${key}". Returning default state.`, deserializationError);
968
+ onValidationFailed?.(deserializationError);
969
+ return defaultState;
970
+ }
971
+ if (version === undefined) {
972
+ return validateOrSalvage({
973
+ rawValue: deserialized,
974
+ schema,
975
+ defaultState,
976
+ key,
977
+ onValidationFailed,
978
+ onValidationSalvaged,
979
+ });
980
+ }
981
+ let storedVersion;
982
+ let data;
983
+ const envelopeResult = storageVersionEnvelopeSchema.safeParse(deserialized);
984
+ if (envelopeResult.success) {
985
+ storedVersion = envelopeResult.data.__version;
986
+ data = envelopeResult.data.__data;
987
+ }
988
+ else {
989
+ storedVersion = 0;
990
+ data = deserialized;
991
+ }
992
+ if (steps !== undefined && storedVersion < version) {
993
+ try {
994
+ data = runMigrations({ data, fromVersion: storedVersion, toVersion: version, migrations: steps });
995
+ }
996
+ catch (migrationError) {
997
+ // eslint-disable-next-line no-console
998
+ console.error(`Migration failed for storage key "${key}" (v${storedVersion} → v${version}). Returning default state.`, migrationError);
999
+ return defaultState;
1000
+ }
1001
+ }
1002
+ return validateOrSalvage({ rawValue: data, schema, defaultState, key, onValidationFailed, onValidationSalvaged });
1003
+ };
1004
+
1005
+ /**
1006
+ * Validates the state using a Zod schema. Returns the parsed state on success,
1007
+ * or defaultState on failure (with error logging and optional callbacks).
1008
+ *
1009
+ * @template TState - The type of the state.
1010
+ * @param params - The state, schema, defaultState, and optional callbacks.
1011
+ * @param params.state - The raw state to validate.
1012
+ * @param params.schema - The Zod schema for validation.
1013
+ * @param params.defaultState - The fallback value on failure.
1014
+ * @param params.onValidationFailed - Optional error callback.
1015
+ * @param params.onValidationSuccessful - Optional success callback.
1016
+ * @returns {TState} The validated state or defaultState.
1017
+ */
1018
+ const validateState = ({ state, schema, onValidationFailed, onValidationSuccessful, defaultState, }) => {
1019
+ const result = schema.safeParse(state);
1020
+ if (result.success) {
1021
+ onValidationSuccessful?.(result.data);
1022
+ return result.data;
1023
+ }
1024
+ // eslint-disable-next-line no-console
1025
+ console.error("Failed to parse and validate the state from storage.", result.error);
1026
+ onValidationFailed?.(result.error);
1027
+ return defaultState;
1028
+ };
1029
+
1030
+ /**
1031
+ * Keys reserved for internal storage envelope metadata.
1032
+ * Consumer state objects must not use these as top-level keys.
1033
+ */
1034
+ const RESERVED_STORAGE_KEYS = ["__data", "__version", "__serializer"];
1035
+ const warnIfReservedKeys = (value) => {
1036
+ if (value === null || typeof value !== "object" || Array.isArray(value))
1037
+ return;
1038
+ for (const key of RESERVED_STORAGE_KEYS) {
1039
+ if (key in value) {
1040
+ // eslint-disable-next-line no-console
1041
+ console.warn(`[useWebStorage] "${key}" is a reserved internal storage key and must not be used in state objects. ` +
1042
+ `It will conflict with the storage serialization envelope.`);
1043
+ }
1044
+ }
1045
+ };
1046
+ /**
1047
+ * Serializes and writes a value to web storage.
1048
+ * When a version is provided, wraps the data in a versioned envelope
1049
+ * before serializing so the migration pipeline can detect it on read.
1050
+ *
1051
+ * @param storage - The Storage instance (localStorage / sessionStorage).
1052
+ * @param key - The storage key.
1053
+ * @param value - The value to serialize and store.
1054
+ * @param version - Optional schema version to stamp onto the stored payload.
1055
+ */
1056
+ const writeToStorage = (storage, key, value, version) => {
1057
+ warnIfReservedKeys(value);
1058
+ const payload = version === undefined ? value : createStorageVersionEnvelope(value, version);
1059
+ storage.setItem(key, storageSerializer.serialize(payload));
1060
+ };
1061
+
1062
+ /**
1063
+ * Reads and validates a value from `localStorage`, pre-bound to `globalThis.localStorage`
1064
+ * so callers never reference the `localStorage` global directly (and therefore never need
1065
+ * a `no-direct-storage-access` lint exemption).
1066
+ *
1067
+ * This is the non-hook counterpart to `useLocalStorage` (in `@trackunit/react-components`),
1068
+ * for the rare call site that has no React component/hook to attach a hook to (e.g.
1069
+ * module-scope bootstrap code, or a callback invoked by framework internals outside of
1070
+ * render — see `useLocalStorage`'s docs for when to prefer the hook instead). Uses the same
1071
+ * schema validation, salvage, superjson serialization, and migration pipeline as
1072
+ * `useLocalStorage`.
1073
+ *
1074
+ * @example
1075
+ * ```ts
1076
+ * import { readLocalStorageValue } from "@trackunit/shared-utils";
1077
+ *
1078
+ * const reloadData = readLocalStorageValue({
1079
+ * key: "irisAppLoader:reload",
1080
+ * defaultState: { count: 0, firstReloadTime: null },
1081
+ * schema: z.object({ count: z.number(), firstReloadTime: z.number().nullable() }),
1082
+ * });
1083
+ * ```
1084
+ */
1085
+ const readLocalStorageValue = (options) => readFromStorage({ storage: globalThis.localStorage, ...options });
1086
+ /**
1087
+ * Validates and writes a value to `localStorage`, pre-bound to `globalThis.localStorage`.
1088
+ * See {@link readLocalStorageValue} for why this exists alongside `useLocalStorage`.
1089
+ *
1090
+ * Symmetrical with the read side: `value` is validated against `schema` before writing,
1091
+ * exactly like the hook's write path (`useStorageSyncEffect` runs `validateState` before
1092
+ * `writeToStorage`). On validation failure, nothing is written and any existing value at
1093
+ * `key` is removed — this keeps `writeLocalStorageValue` from being the one place in the
1094
+ * library that could persist schema-violating data for a `useLocalStorage`/
1095
+ * `readLocalStorageValue` reader to later find and silently discard.
1096
+ *
1097
+ * @param params - Key, value, schema, and optional version/callbacks.
1098
+ * @param params.key - The storage key.
1099
+ * @param params.value - The value to validate, serialize, and store.
1100
+ * @param params.schema - Zod schema the value must satisfy before it's written.
1101
+ * @param params.version - Optional schema version to stamp onto the stored payload (see `MigrationConfig`).
1102
+ * @param params.onValidationFailed - Called instead of writing, when `value` fails schema validation.
1103
+ * @param params.onValidationSuccessful - Called after a successful write.
1104
+ */
1105
+ const writeLocalStorageValue = ({ key, value, schema, version, onValidationFailed, onValidationSuccessful, }) => {
1106
+ validateState({
1107
+ state: value,
1108
+ schema,
1109
+ defaultState: value,
1110
+ onValidationFailed: error => {
1111
+ globalThis.localStorage.removeItem(key);
1112
+ onValidationFailed?.(error);
1113
+ },
1114
+ onValidationSuccessful: data => {
1115
+ writeToStorage(globalThis.localStorage, key, data, version);
1116
+ onValidationSuccessful?.(data);
1117
+ },
1118
+ });
1119
+ };
1120
+ /**
1121
+ * Removes a key from `localStorage`, pre-bound to `globalThis.localStorage`. A no-op if the
1122
+ * key is already absent.
1123
+ */
1124
+ const removeLocalStorageValue = (key) => {
1125
+ globalThis.localStorage.removeItem(key);
1126
+ };
1127
+
708
1128
  /**
709
1129
  * Deletes all undefined keys from an object.
710
1130
  *
@@ -1200,7 +1620,7 @@ function getLoremIpsum({ paragraphs = 1, asArray = false } = {}) {
1200
1620
  }
1201
1621
  const sentences = firstParagraph.split(/\. /).filter(s => s.length > 0);
1202
1622
  const halfLength = Math.ceil(sentences.length / 2);
1203
- const halfText = sentences.slice(0, halfLength).join(". ") + ".";
1623
+ const halfText = `${sentences.slice(0, halfLength).join(". ")}.`;
1204
1624
  return halfText;
1205
1625
  }
1206
1626
  // Handle numeric paragraphs
@@ -1677,7 +2097,10 @@ exports.objectValues = objectValues;
1677
2097
  exports.parseTailwindArbitraryValue = parseTailwindArbitraryValue;
1678
2098
  exports.pick = pick;
1679
2099
  exports.preload = preload;
2100
+ exports.readFromStorage = readFromStorage;
2101
+ exports.readLocalStorageValue = readLocalStorageValue;
1680
2102
  exports.removeLeftPadding = removeLeftPadding;
2103
+ exports.removeLocalStorageValue = removeLocalStorageValue;
1681
2104
  exports.removeProperties = removeProperties;
1682
2105
  exports.removeProperty = removeProperty;
1683
2106
  exports.replaceNullableNumbersWithZero = replaceNullableNumbersWithZero;
@@ -1686,6 +2109,7 @@ exports.resizeBlob = resizeBlob;
1686
2109
  exports.resizeImage = resizeImage;
1687
2110
  exports.rgb2hex = rgb2hex;
1688
2111
  exports.size = size;
2112
+ exports.storageSerializer = storageSerializer;
1689
2113
  exports.stringCompare = stringCompare;
1690
2114
  exports.stringCompareFromKey = stringCompareFromKey;
1691
2115
  exports.stringNaturalCompare = stringNaturalCompare;
@@ -1703,3 +2127,6 @@ exports.unionArraysByKey = unionArraysByKey;
1703
2127
  exports.uuidv3 = uuidv3;
1704
2128
  exports.uuidv4 = uuidv4;
1705
2129
  exports.uuidv5 = uuidv5;
2130
+ exports.validateState = validateState;
2131
+ exports.writeLocalStorageValue = writeLocalStorageValue;
2132
+ exports.writeToStorage = writeToStorage;
package/index.esm.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { isEqual } from 'es-toolkit';
1
2
  import { z } from 'zod';
3
+ import superjson from 'superjson';
2
4
  import { v3, v4, v5 } from 'uuid';
3
5
 
4
6
  /**
@@ -547,7 +549,7 @@ const insertToUUID = (arr, position, value) => [
547
549
  * @param input A string.
548
550
  * @param maxLength The maximum length of the string that it should pad for.
549
551
  */
550
- const zeroPadToUUIDLength = (input, maxLength) => input.length < maxLength ? zeroPadToUUIDLength("0" + input, maxLength) : input;
552
+ const zeroPadToUUIDLength = (input, maxLength) => input.length < maxLength ? zeroPadToUUIDLength(`0${input}`, maxLength) : input;
551
553
  /**
552
554
  * A helper function that converts any value into a UUID.
553
555
  *
@@ -703,6 +705,424 @@ const fetchImageAsBase64 = async (url) => {
703
705
  });
704
706
  };
705
707
 
708
+ /**
709
+ * Runs a sequential migration pipeline on the provided data, applying
710
+ * each migration whose version is in the range (fromVersion, toVersion].
711
+ *
712
+ * Throws if the filtered migrations have non-contiguous versions (gaps).
713
+ */
714
+ const runMigrations = ({ data, fromVersion, toVersion, migrations, }) => {
715
+ if (toVersion <= fromVersion) {
716
+ return data;
717
+ }
718
+ const applicable = migrations
719
+ .toSorted((a, b) => a.version - b.version)
720
+ .filter(m => m.version > fromVersion && m.version <= toVersion);
721
+ if (applicable.length === 0) {
722
+ return data;
723
+ }
724
+ for (let i = 1; i < applicable.length; i++) {
725
+ const prev = applicable[i - 1];
726
+ const curr = applicable[i];
727
+ if (prev && curr && curr.version !== prev.version + 1) {
728
+ throw new Error(`Migration gap detected: found version ${prev.version} and ${curr.version} but no migration for version ${prev.version + 1}.`);
729
+ }
730
+ }
731
+ return applicable.reduce((acc, migration) => migration.migrate(acc), data);
732
+ };
733
+
734
+ /**
735
+ * Recursively builds a salvaging schema from a ZodObject by injecting `.catch(defaultValue[key])`
736
+ * for each field. This means invalid field values fall back to defaultState values rather than
737
+ * failing the entire parse.
738
+ *
739
+ * Unwraps ZodOptional, ZodNullable, and ZodDefault to reach the inner schema.
740
+ * ZodCatch is intentionally NOT unwrapped — consumer-defined catches take precedence.
741
+ * Non-ZodObject schemas (unions, discriminated unions, refinements, etc.) are returned unchanged
742
+ * so they behave as atomic units.
743
+ */
744
+ function buildSalvagingSchema(schema, defaultValue) {
745
+ if (schema instanceof z.ZodOptional) {
746
+ return buildSalvagingSchema(schema.unwrap(), defaultValue).optional();
747
+ }
748
+ if (schema instanceof z.ZodNullable) {
749
+ return buildSalvagingSchema(schema.unwrap(), defaultValue).nullable();
750
+ }
751
+ if (schema instanceof z.ZodDefault) {
752
+ return buildSalvagingSchema(schema._def.innerType, defaultValue);
753
+ }
754
+ if (!(schema instanceof z.ZodObject)) {
755
+ return schema;
756
+ }
757
+ const defaultRecordResult = z.record(z.string(), z.unknown()).safeParse(defaultValue);
758
+ if (!defaultRecordResult.success) {
759
+ return schema;
760
+ }
761
+ const defaultRecord = defaultRecordResult.data;
762
+ const salvagingShape = {};
763
+ for (const key of Object.keys(schema.shape)) {
764
+ const fieldSchema = schema.shape[key];
765
+ if (fieldSchema === undefined)
766
+ continue;
767
+ const fieldDefault = defaultRecord[key];
768
+ salvagingShape[key] = buildSalvagingSchema(fieldSchema, fieldDefault).catch(fieldDefault);
769
+ }
770
+ return z.object(salvagingShape);
771
+ }
772
+ /**
773
+ * Attempts to salvage partial state from raw data when full schema validation has failed.
774
+ *
775
+ * For ZodObject schemas, rebuilds the schema with per-field `.catch(defaultState[key])` fallbacks
776
+ * and reparses. Valid field values from the stored data are kept; invalid fields fall back to their
777
+ * corresponding values in `defaultState`. Works recursively for nested ZodObject fields.
778
+ *
779
+ * Non-object schemas (discriminated unions, unions with `.refine()`, etc.) cannot be partially
780
+ * salvaged and return `null`.
781
+ *
782
+ * Consumer-defined `.catch()` wrappers take precedence over the injected salvage catches, since
783
+ * Zod processes catches inside-out.
784
+ *
785
+ * Note: when every field is invalid, the salvaged result will be deeply equal to `defaultState`.
786
+ * Callers should compare the result against `defaultState` to distinguish a genuine partial
787
+ * salvage from a total loss where no stored data survived.
788
+ *
789
+ * @template TState - The type of the stored state.
790
+ * @param schema - Zod schema for validation.
791
+ * @param rawData - The raw deserialized value that failed validation.
792
+ * @param defaultState - The authoritative fallback value; used as per-field catch defaults.
793
+ * @returns {TState | null} The salvaged state, or `null` if salvaging is not possible or also fails.
794
+ */
795
+ const salvageState = (schema, rawData, defaultState) => {
796
+ try {
797
+ const salvagingSchema = buildSalvagingSchema(schema, defaultState);
798
+ const intermediate = salvagingSchema.safeParse(rawData);
799
+ if (!intermediate.success) {
800
+ return null;
801
+ }
802
+ // Re-validate through the original schema to ensure correctness and get the proper TState type.
803
+ // By construction this should always succeed: each field value is either the valid stored value
804
+ // or defaultState[key], both of which satisfy the original schema.
805
+ const result = schema.safeParse(intermediate.data);
806
+ return result.success ? result.data : null;
807
+ }
808
+ catch {
809
+ return null;
810
+ }
811
+ };
812
+
813
+ /**
814
+ * Internal envelope used to tag superjson-serialized data in web storage.
815
+ *
816
+ * `__serializer` is a **reserved internal key** — consumer state objects must
817
+ * not include it as a top-level key. The double-underscore prefix is a
818
+ * deliberate signal that this is a private implementation detail.
819
+ *
820
+ * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
821
+ * detected in the value being written.
822
+ */
823
+ const taggedSuperjsonEnvelopeSchema = z.object({
824
+ __serializer: z.literal("superjson"),
825
+ json: z.custom(),
826
+ meta: z.custom().optional(),
827
+ });
828
+ const storageSerializer = {
829
+ serialize: (value) => {
830
+ const serialized = superjson.serialize(value);
831
+ return JSON.stringify({ __serializer: "superjson", ...serialized });
832
+ },
833
+ deserialize: (value) => {
834
+ const parsed = JSON.parse(value);
835
+ const result = taggedSuperjsonEnvelopeSchema.safeParse(parsed);
836
+ if (result.success) {
837
+ return superjson.deserialize({ json: result.data.json, meta: result.data.meta });
838
+ }
839
+ return parsed;
840
+ },
841
+ };
842
+
843
+ /**
844
+ * Internal envelope that pairs stored data with a schema version number.
845
+ *
846
+ * `__version` and `__data` are **reserved internal keys** — consumer state
847
+ * objects must not include them as top-level keys. The double-underscore
848
+ * prefix is a deliberate signal that these are private implementation details.
849
+ *
850
+ * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
851
+ * detected in the value being written.
852
+ */
853
+ const storageVersionEnvelopeSchema = z
854
+ .object({
855
+ __version: z.number(),
856
+ __data: z.unknown(),
857
+ })
858
+ .refine((x) => "__data" in x, {
859
+ message: "__data is required",
860
+ });
861
+ /** Wraps data and version into a versioned storage envelope. */
862
+ const createStorageVersionEnvelope = (data, version) => ({
863
+ __version: version,
864
+ __data: data,
865
+ });
866
+
867
+ /**
868
+ * Validates raw data against the schema, attempting partial salvage on failure.
869
+ *
870
+ * On success: returns the parsed data.
871
+ * On failure with a salvageable ZodObject schema where at least one stored field
872
+ * survived: returns the salvaged state and calls `onValidationSalvaged`.
873
+ * On failure where salvage produces a result identical to `defaultState` (i.e. every
874
+ * field was invalid and caught to its default): treats this as a total failure and
875
+ * calls `onValidationFailed` instead of `onValidationSalvaged`.
876
+ * On failure with no salvage possible: returns `defaultState` and calls `onValidationFailed`.
877
+ */
878
+ const validateOrSalvage = ({ rawValue, schema, defaultState, key, onValidationFailed, onValidationSalvaged, }) => {
879
+ const parseResult = schema.safeParse(rawValue);
880
+ if (parseResult.success) {
881
+ return parseResult.data;
882
+ }
883
+ const salvaged = salvageState(schema, rawValue, defaultState);
884
+ if (salvaged !== null && !isEqual(salvaged, defaultState)) {
885
+ // eslint-disable-next-line no-console
886
+ console.warn(`Partially invalid data in storage key "${key}" — salvaged valid fields, reset invalid fields to defaults.`, parseResult.error);
887
+ onValidationSalvaged?.(parseResult.error, salvaged);
888
+ return salvaged;
889
+ }
890
+ // eslint-disable-next-line no-console
891
+ console.error(`Failed to parse and validate the state from storage key "${key}". Returning default state.`, parseResult.error);
892
+ onValidationFailed?.(parseResult.error);
893
+ return defaultState;
894
+ };
895
+ /**
896
+ * Reads and deserializes a value from web storage, validating it against the provided schema.
897
+ * Falls back to defaultState if the key is missing or data is corrupt.
898
+ *
899
+ * On partial validation failure for ZodObject schemas, salvages valid fields rather than
900
+ * resetting the entire state to `defaultState`. See `salvageState` for salvage mechanics.
901
+ * If the salvaged result is deeply equal to `defaultState` (i.e. every field was invalid),
902
+ * the salvage is considered a total loss and `onValidationFailed` is called instead of
903
+ * `onValidationSalvaged`.
904
+ *
905
+ * Deserialization failures (unparseable/corrupt raw data) are routed through
906
+ * `onValidationFailed` so consumers are always notified when stored data is unusable.
907
+ *
908
+ * `legacyRawString` is a narrow, opt-in escape hatch for keys that are known to hold a
909
+ * plain-text value that was never JSON-encoded in the first place (e.g. a legacy username
910
+ * written via `localStorage.setItem` directly, with no `JSON.stringify`). When set, a raw
911
+ * value that fails JSON parsing is treated as the candidate string itself rather than a
912
+ * hard failure, and handed to schema validation (a permissive schema like `z.string()`
913
+ * recovers it; a schema expecting structured data still correctly rejects it). Leave this
914
+ * unset for every other key — enabling it globally would let a genuinely corrupted,
915
+ * truncated envelope be silently accepted as a valid string and re-persisted as such on the
916
+ * next write, permanently hiding the corruption. Scope it only to keys that are provably
917
+ * never written by anything other than a plain string.
918
+ *
919
+ * When `migration` is provided, detects versioned envelopes, runs the
920
+ * migration pipeline, and validates the migrated result. Non-versioned consumers are
921
+ * unaffected — the migration path is fully opt-in.
922
+ *
923
+ * @template TState - The type of the stored state.
924
+ * @param params - Storage, key, default state, Zod schema, optional migration config, and optional callbacks.
925
+ * @param params.storage - The web Storage instance.
926
+ * @param params.key - The storage key.
927
+ * @param params.defaultState - Fallback value when no stored data exists or data is corrupt.
928
+ * @param params.schema - Zod schema to validate the deserialized data.
929
+ * @param params.migration - Optional migration configuration (version, steps, fromKey).
930
+ * @param params.legacyRawString - Opt-in recovery for keys known to hold a plain-text value
931
+ * that was never JSON-encoded. See the note above the function for when (not) to use this.
932
+ * @param params.onValidationFailed - Called when validation fails and no salvage is possible, or when
933
+ * deserialization of the raw storage value fails entirely.
934
+ * @param params.onValidationSalvaged - Called when validation fails but at least one stored field
935
+ * was recovered (salvaged result differs from defaultState).
936
+ * @returns {TState} The validated or salvaged state, or defaultState.
937
+ */
938
+ const readFromStorage = ({ storage, key, defaultState, schema, migration, legacyRawString, onValidationFailed, onValidationSalvaged, }) => {
939
+ const version = migration?.version;
940
+ const steps = migration?.steps;
941
+ const fromKey = migration?.fromKey;
942
+ let raw = storage.getItem(key);
943
+ if (raw === null && fromKey !== undefined) {
944
+ raw = storage.getItem(fromKey);
945
+ }
946
+ if (raw === null) {
947
+ return defaultState;
948
+ }
949
+ let deserialized;
950
+ try {
951
+ deserialized = storageSerializer.deserialize(raw);
952
+ }
953
+ catch (deserializationError) {
954
+ if (legacyRawString) {
955
+ return validateOrSalvage({
956
+ rawValue: raw,
957
+ schema,
958
+ defaultState,
959
+ key,
960
+ onValidationFailed,
961
+ onValidationSalvaged,
962
+ });
963
+ }
964
+ // eslint-disable-next-line no-console
965
+ console.error(`Failed to deserialize storage key "${key}". Returning default state.`, deserializationError);
966
+ onValidationFailed?.(deserializationError);
967
+ return defaultState;
968
+ }
969
+ if (version === undefined) {
970
+ return validateOrSalvage({
971
+ rawValue: deserialized,
972
+ schema,
973
+ defaultState,
974
+ key,
975
+ onValidationFailed,
976
+ onValidationSalvaged,
977
+ });
978
+ }
979
+ let storedVersion;
980
+ let data;
981
+ const envelopeResult = storageVersionEnvelopeSchema.safeParse(deserialized);
982
+ if (envelopeResult.success) {
983
+ storedVersion = envelopeResult.data.__version;
984
+ data = envelopeResult.data.__data;
985
+ }
986
+ else {
987
+ storedVersion = 0;
988
+ data = deserialized;
989
+ }
990
+ if (steps !== undefined && storedVersion < version) {
991
+ try {
992
+ data = runMigrations({ data, fromVersion: storedVersion, toVersion: version, migrations: steps });
993
+ }
994
+ catch (migrationError) {
995
+ // eslint-disable-next-line no-console
996
+ console.error(`Migration failed for storage key "${key}" (v${storedVersion} → v${version}). Returning default state.`, migrationError);
997
+ return defaultState;
998
+ }
999
+ }
1000
+ return validateOrSalvage({ rawValue: data, schema, defaultState, key, onValidationFailed, onValidationSalvaged });
1001
+ };
1002
+
1003
+ /**
1004
+ * Validates the state using a Zod schema. Returns the parsed state on success,
1005
+ * or defaultState on failure (with error logging and optional callbacks).
1006
+ *
1007
+ * @template TState - The type of the state.
1008
+ * @param params - The state, schema, defaultState, and optional callbacks.
1009
+ * @param params.state - The raw state to validate.
1010
+ * @param params.schema - The Zod schema for validation.
1011
+ * @param params.defaultState - The fallback value on failure.
1012
+ * @param params.onValidationFailed - Optional error callback.
1013
+ * @param params.onValidationSuccessful - Optional success callback.
1014
+ * @returns {TState} The validated state or defaultState.
1015
+ */
1016
+ const validateState = ({ state, schema, onValidationFailed, onValidationSuccessful, defaultState, }) => {
1017
+ const result = schema.safeParse(state);
1018
+ if (result.success) {
1019
+ onValidationSuccessful?.(result.data);
1020
+ return result.data;
1021
+ }
1022
+ // eslint-disable-next-line no-console
1023
+ console.error("Failed to parse and validate the state from storage.", result.error);
1024
+ onValidationFailed?.(result.error);
1025
+ return defaultState;
1026
+ };
1027
+
1028
+ /**
1029
+ * Keys reserved for internal storage envelope metadata.
1030
+ * Consumer state objects must not use these as top-level keys.
1031
+ */
1032
+ const RESERVED_STORAGE_KEYS = ["__data", "__version", "__serializer"];
1033
+ const warnIfReservedKeys = (value) => {
1034
+ if (value === null || typeof value !== "object" || Array.isArray(value))
1035
+ return;
1036
+ for (const key of RESERVED_STORAGE_KEYS) {
1037
+ if (key in value) {
1038
+ // eslint-disable-next-line no-console
1039
+ console.warn(`[useWebStorage] "${key}" is a reserved internal storage key and must not be used in state objects. ` +
1040
+ `It will conflict with the storage serialization envelope.`);
1041
+ }
1042
+ }
1043
+ };
1044
+ /**
1045
+ * Serializes and writes a value to web storage.
1046
+ * When a version is provided, wraps the data in a versioned envelope
1047
+ * before serializing so the migration pipeline can detect it on read.
1048
+ *
1049
+ * @param storage - The Storage instance (localStorage / sessionStorage).
1050
+ * @param key - The storage key.
1051
+ * @param value - The value to serialize and store.
1052
+ * @param version - Optional schema version to stamp onto the stored payload.
1053
+ */
1054
+ const writeToStorage = (storage, key, value, version) => {
1055
+ warnIfReservedKeys(value);
1056
+ const payload = version === undefined ? value : createStorageVersionEnvelope(value, version);
1057
+ storage.setItem(key, storageSerializer.serialize(payload));
1058
+ };
1059
+
1060
+ /**
1061
+ * Reads and validates a value from `localStorage`, pre-bound to `globalThis.localStorage`
1062
+ * so callers never reference the `localStorage` global directly (and therefore never need
1063
+ * a `no-direct-storage-access` lint exemption).
1064
+ *
1065
+ * This is the non-hook counterpart to `useLocalStorage` (in `@trackunit/react-components`),
1066
+ * for the rare call site that has no React component/hook to attach a hook to (e.g.
1067
+ * module-scope bootstrap code, or a callback invoked by framework internals outside of
1068
+ * render — see `useLocalStorage`'s docs for when to prefer the hook instead). Uses the same
1069
+ * schema validation, salvage, superjson serialization, and migration pipeline as
1070
+ * `useLocalStorage`.
1071
+ *
1072
+ * @example
1073
+ * ```ts
1074
+ * import { readLocalStorageValue } from "@trackunit/shared-utils";
1075
+ *
1076
+ * const reloadData = readLocalStorageValue({
1077
+ * key: "irisAppLoader:reload",
1078
+ * defaultState: { count: 0, firstReloadTime: null },
1079
+ * schema: z.object({ count: z.number(), firstReloadTime: z.number().nullable() }),
1080
+ * });
1081
+ * ```
1082
+ */
1083
+ const readLocalStorageValue = (options) => readFromStorage({ storage: globalThis.localStorage, ...options });
1084
+ /**
1085
+ * Validates and writes a value to `localStorage`, pre-bound to `globalThis.localStorage`.
1086
+ * See {@link readLocalStorageValue} for why this exists alongside `useLocalStorage`.
1087
+ *
1088
+ * Symmetrical with the read side: `value` is validated against `schema` before writing,
1089
+ * exactly like the hook's write path (`useStorageSyncEffect` runs `validateState` before
1090
+ * `writeToStorage`). On validation failure, nothing is written and any existing value at
1091
+ * `key` is removed — this keeps `writeLocalStorageValue` from being the one place in the
1092
+ * library that could persist schema-violating data for a `useLocalStorage`/
1093
+ * `readLocalStorageValue` reader to later find and silently discard.
1094
+ *
1095
+ * @param params - Key, value, schema, and optional version/callbacks.
1096
+ * @param params.key - The storage key.
1097
+ * @param params.value - The value to validate, serialize, and store.
1098
+ * @param params.schema - Zod schema the value must satisfy before it's written.
1099
+ * @param params.version - Optional schema version to stamp onto the stored payload (see `MigrationConfig`).
1100
+ * @param params.onValidationFailed - Called instead of writing, when `value` fails schema validation.
1101
+ * @param params.onValidationSuccessful - Called after a successful write.
1102
+ */
1103
+ const writeLocalStorageValue = ({ key, value, schema, version, onValidationFailed, onValidationSuccessful, }) => {
1104
+ validateState({
1105
+ state: value,
1106
+ schema,
1107
+ defaultState: value,
1108
+ onValidationFailed: error => {
1109
+ globalThis.localStorage.removeItem(key);
1110
+ onValidationFailed?.(error);
1111
+ },
1112
+ onValidationSuccessful: data => {
1113
+ writeToStorage(globalThis.localStorage, key, data, version);
1114
+ onValidationSuccessful?.(data);
1115
+ },
1116
+ });
1117
+ };
1118
+ /**
1119
+ * Removes a key from `localStorage`, pre-bound to `globalThis.localStorage`. A no-op if the
1120
+ * key is already absent.
1121
+ */
1122
+ const removeLocalStorageValue = (key) => {
1123
+ globalThis.localStorage.removeItem(key);
1124
+ };
1125
+
706
1126
  /**
707
1127
  * Deletes all undefined keys from an object.
708
1128
  *
@@ -1198,7 +1618,7 @@ function getLoremIpsum({ paragraphs = 1, asArray = false } = {}) {
1198
1618
  }
1199
1619
  const sentences = firstParagraph.split(/\. /).filter(s => s.length > 0);
1200
1620
  const halfLength = Math.ceil(sentences.length / 2);
1201
- const halfText = sentences.slice(0, halfLength).join(". ") + ".";
1621
+ const halfText = `${sentences.slice(0, halfLength).join(". ")}.`;
1202
1622
  return halfText;
1203
1623
  }
1204
1624
  // Handle numeric paragraphs
@@ -1609,4 +2029,4 @@ const formatUsCustomaryDistance = (value) => {
1609
2029
  }
1610
2030
  };
1611
2031
 
1612
- export { DateTimeFormat, HoursAndMinutesFormat, UnitsOfMeasurementSI, UnitsOfMeasurementUSCustomary, VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, align, alphabeticallySort, arrayLengthCompare, arrayNotEmpty, booleanCompare, calculateImageScaleRatio, capitalize, colorsFromStyleDeclaration, convertBlobToBase64, convertMetersToKilometers, convertMetersToYards, convertYardsToMeters, convertYardsToMiles, dateCompare, deleteUndefinedKeys, difference, doNothing, enumFromValue, enumFromValueTypesafe, enumOrUndefinedFromValue, exhaustiveCheck, fetchImageAsBase64, filterByMultiple, formatAddress, formatCoordinates, formatSiDistance, formatUsCustomaryDistance, fuzzySearch, getAllColors, getCountryName, getDifferenceBetweenDates, getEndOfDay, getFirstLevelObjectPropertyDifferences, getISOStringFromDate, getLoremIpsum, getMimeTypeFromDataURL, getResizedDimensions, getStartOfDay, groupBy, groupTinyDataToOthers, hourIntervals, intersection, isArrayEqual, isNavigatingAway, isSortByProperty, isSorted, isUUID, isValidImage, loadSVGDimensions, markNavigatingAway, nonNullable, numberCompare, numberCompareUnknownAfterHighest, objNotEmpty, objectEntries, objectFromEntries, objectKeys, objectValues, parseTailwindArbitraryValue, pick, preload, removeLeftPadding, removeProperties, removeProperty, replaceNullableNumbersWithZero, resetNavigatingAwayForTests, resizeBlob, resizeImage, rgb2hex, size, stringCompare, stringCompareFromKey, stringNaturalCompare, stripHiddenCharacters, svgToPNG, titleCase, toID, toIDs, toPNG, toUUID, trimIds, trimPath, truthy, unionArraysByKey, uuidv3, uuidv4, uuidv5 };
2032
+ export { DateTimeFormat, HoursAndMinutesFormat, UnitsOfMeasurementSI, UnitsOfMeasurementUSCustomary, VISIBLE_ONLY_COLUMN_VISIBILITY_KEY, align, alphabeticallySort, arrayLengthCompare, arrayNotEmpty, booleanCompare, calculateImageScaleRatio, capitalize, colorsFromStyleDeclaration, convertBlobToBase64, convertMetersToKilometers, convertMetersToYards, convertYardsToMeters, convertYardsToMiles, dateCompare, deleteUndefinedKeys, difference, doNothing, enumFromValue, enumFromValueTypesafe, enumOrUndefinedFromValue, exhaustiveCheck, fetchImageAsBase64, filterByMultiple, formatAddress, formatCoordinates, formatSiDistance, formatUsCustomaryDistance, fuzzySearch, getAllColors, getCountryName, getDifferenceBetweenDates, getEndOfDay, getFirstLevelObjectPropertyDifferences, getISOStringFromDate, getLoremIpsum, getMimeTypeFromDataURL, getResizedDimensions, getStartOfDay, groupBy, groupTinyDataToOthers, hourIntervals, intersection, isArrayEqual, isNavigatingAway, isSortByProperty, isSorted, isUUID, isValidImage, loadSVGDimensions, markNavigatingAway, nonNullable, numberCompare, numberCompareUnknownAfterHighest, objNotEmpty, objectEntries, objectFromEntries, objectKeys, objectValues, parseTailwindArbitraryValue, pick, preload, readFromStorage, readLocalStorageValue, removeLeftPadding, removeLocalStorageValue, removeProperties, removeProperty, replaceNullableNumbersWithZero, resetNavigatingAwayForTests, resizeBlob, resizeImage, rgb2hex, size, storageSerializer, stringCompare, stringCompareFromKey, stringNaturalCompare, stripHiddenCharacters, svgToPNG, titleCase, toID, toIDs, toPNG, toUUID, trimIds, trimPath, truthy, unionArraysByKey, uuidv3, uuidv4, uuidv5, validateState, writeLocalStorageValue, writeToStorage };
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "@trackunit/shared-utils",
3
- "version": "1.16.4",
3
+ "version": "1.16.5",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "engines": {
6
6
  "node": ">=24.x"
7
7
  },
8
8
  "license": "SEE LICENSE IN LICENSE.txt",
9
9
  "dependencies": {
10
+ "es-toolkit": "^1.39.10",
11
+ "superjson": "^2.2.6",
10
12
  "uuid": "^11.1.0",
11
13
  "zod": "^3.25.76"
12
14
  },
package/src/index.d.ts CHANGED
@@ -18,6 +18,12 @@ export * from "./groupBy/groupBy";
18
18
  export * from "./GroupingUtility";
19
19
  export * from "./idUtils";
20
20
  export * from "./imageTools";
21
+ export * from "./localStorage/localStorageAccessors";
22
+ export * from "./localStorage/readFromStorage";
23
+ export * from "./localStorage/storageSerializer";
24
+ export * from "./localStorage/types";
25
+ export * from "./localStorage/validateState";
26
+ export * from "./localStorage/writeToStorage";
21
27
  export * from "./Maybe";
22
28
  export * from "./objectUtils";
23
29
  export * from "./pathUtils";
@@ -0,0 +1,60 @@
1
+ import type { z } from "zod";
2
+ import { readFromStorage } from "./readFromStorage";
3
+ import type { WebStorageCallbacks } from "./types";
4
+ type ReadLocalStorageOptions<TState> = Omit<Parameters<typeof readFromStorage<TState>>[0], "storage">;
5
+ /**
6
+ * Reads and validates a value from `localStorage`, pre-bound to `globalThis.localStorage`
7
+ * so callers never reference the `localStorage` global directly (and therefore never need
8
+ * a `no-direct-storage-access` lint exemption).
9
+ *
10
+ * This is the non-hook counterpart to `useLocalStorage` (in `@trackunit/react-components`),
11
+ * for the rare call site that has no React component/hook to attach a hook to (e.g.
12
+ * module-scope bootstrap code, or a callback invoked by framework internals outside of
13
+ * render — see `useLocalStorage`'s docs for when to prefer the hook instead). Uses the same
14
+ * schema validation, salvage, superjson serialization, and migration pipeline as
15
+ * `useLocalStorage`.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { readLocalStorageValue } from "@trackunit/shared-utils";
20
+ *
21
+ * const reloadData = readLocalStorageValue({
22
+ * key: "irisAppLoader:reload",
23
+ * defaultState: { count: 0, firstReloadTime: null },
24
+ * schema: z.object({ count: z.number(), firstReloadTime: z.number().nullable() }),
25
+ * });
26
+ * ```
27
+ */
28
+ export declare const readLocalStorageValue: <TState>(options: ReadLocalStorageOptions<TState>) => TState;
29
+ type WriteLocalStorageOptions<TState> = {
30
+ readonly key: string;
31
+ readonly value: TState;
32
+ readonly schema: z.ZodType<TState>;
33
+ readonly version?: number;
34
+ } & Pick<WebStorageCallbacks<TState>, "onValidationFailed" | "onValidationSuccessful">;
35
+ /**
36
+ * Validates and writes a value to `localStorage`, pre-bound to `globalThis.localStorage`.
37
+ * See {@link readLocalStorageValue} for why this exists alongside `useLocalStorage`.
38
+ *
39
+ * Symmetrical with the read side: `value` is validated against `schema` before writing,
40
+ * exactly like the hook's write path (`useStorageSyncEffect` runs `validateState` before
41
+ * `writeToStorage`). On validation failure, nothing is written and any existing value at
42
+ * `key` is removed — this keeps `writeLocalStorageValue` from being the one place in the
43
+ * library that could persist schema-violating data for a `useLocalStorage`/
44
+ * `readLocalStorageValue` reader to later find and silently discard.
45
+ *
46
+ * @param params - Key, value, schema, and optional version/callbacks.
47
+ * @param params.key - The storage key.
48
+ * @param params.value - The value to validate, serialize, and store.
49
+ * @param params.schema - Zod schema the value must satisfy before it's written.
50
+ * @param params.version - Optional schema version to stamp onto the stored payload (see `MigrationConfig`).
51
+ * @param params.onValidationFailed - Called instead of writing, when `value` fails schema validation.
52
+ * @param params.onValidationSuccessful - Called after a successful write.
53
+ */
54
+ export declare const writeLocalStorageValue: <TState>({ key, value, schema, version, onValidationFailed, onValidationSuccessful, }: WriteLocalStorageOptions<TState>) => void;
55
+ /**
56
+ * Removes a key from `localStorage`, pre-bound to `globalThis.localStorage`. A no-op if the
57
+ * key is already absent.
58
+ */
59
+ export declare const removeLocalStorageValue: (key: string) => void;
60
+ export {};
@@ -0,0 +1,53 @@
1
+ import type { z } from "zod";
2
+ import type { MigrationConfig, WebStorageCallbacks } from "./types";
3
+ /**
4
+ * Reads and deserializes a value from web storage, validating it against the provided schema.
5
+ * Falls back to defaultState if the key is missing or data is corrupt.
6
+ *
7
+ * On partial validation failure for ZodObject schemas, salvages valid fields rather than
8
+ * resetting the entire state to `defaultState`. See `salvageState` for salvage mechanics.
9
+ * If the salvaged result is deeply equal to `defaultState` (i.e. every field was invalid),
10
+ * the salvage is considered a total loss and `onValidationFailed` is called instead of
11
+ * `onValidationSalvaged`.
12
+ *
13
+ * Deserialization failures (unparseable/corrupt raw data) are routed through
14
+ * `onValidationFailed` so consumers are always notified when stored data is unusable.
15
+ *
16
+ * `legacyRawString` is a narrow, opt-in escape hatch for keys that are known to hold a
17
+ * plain-text value that was never JSON-encoded in the first place (e.g. a legacy username
18
+ * written via `localStorage.setItem` directly, with no `JSON.stringify`). When set, a raw
19
+ * value that fails JSON parsing is treated as the candidate string itself rather than a
20
+ * hard failure, and handed to schema validation (a permissive schema like `z.string()`
21
+ * recovers it; a schema expecting structured data still correctly rejects it). Leave this
22
+ * unset for every other key — enabling it globally would let a genuinely corrupted,
23
+ * truncated envelope be silently accepted as a valid string and re-persisted as such on the
24
+ * next write, permanently hiding the corruption. Scope it only to keys that are provably
25
+ * never written by anything other than a plain string.
26
+ *
27
+ * When `migration` is provided, detects versioned envelopes, runs the
28
+ * migration pipeline, and validates the migrated result. Non-versioned consumers are
29
+ * unaffected — the migration path is fully opt-in.
30
+ *
31
+ * @template TState - The type of the stored state.
32
+ * @param params - Storage, key, default state, Zod schema, optional migration config, and optional callbacks.
33
+ * @param params.storage - The web Storage instance.
34
+ * @param params.key - The storage key.
35
+ * @param params.defaultState - Fallback value when no stored data exists or data is corrupt.
36
+ * @param params.schema - Zod schema to validate the deserialized data.
37
+ * @param params.migration - Optional migration configuration (version, steps, fromKey).
38
+ * @param params.legacyRawString - Opt-in recovery for keys known to hold a plain-text value
39
+ * that was never JSON-encoded. See the note above the function for when (not) to use this.
40
+ * @param params.onValidationFailed - Called when validation fails and no salvage is possible, or when
41
+ * deserialization of the raw storage value fails entirely.
42
+ * @param params.onValidationSalvaged - Called when validation fails but at least one stored field
43
+ * was recovered (salvaged result differs from defaultState).
44
+ * @returns {TState} The validated or salvaged state, or defaultState.
45
+ */
46
+ export declare const readFromStorage: <TState>({ storage, key, defaultState, schema, migration, legacyRawString, onValidationFailed, onValidationSalvaged, }: {
47
+ readonly storage: Storage;
48
+ readonly key: string;
49
+ readonly defaultState: TState;
50
+ readonly schema: z.ZodType<TState>;
51
+ readonly migration?: MigrationConfig;
52
+ readonly legacyRawString?: boolean;
53
+ } & Pick<WebStorageCallbacks<TState>, "onValidationFailed" | "onValidationSalvaged">) => TState;
@@ -0,0 +1,13 @@
1
+ import type { StorageMigration } from "./types";
2
+ /**
3
+ * Runs a sequential migration pipeline on the provided data, applying
4
+ * each migration whose version is in the range (fromVersion, toVersion].
5
+ *
6
+ * Throws if the filtered migrations have non-contiguous versions (gaps).
7
+ */
8
+ export declare const runMigrations: ({ data, fromVersion, toVersion, migrations, }: {
9
+ readonly data: unknown;
10
+ readonly fromVersion: number;
11
+ readonly toVersion: number;
12
+ readonly migrations: ReadonlyArray<StorageMigration>;
13
+ }) => unknown;
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Attempts to salvage partial state from raw data when full schema validation has failed.
4
+ *
5
+ * For ZodObject schemas, rebuilds the schema with per-field `.catch(defaultState[key])` fallbacks
6
+ * and reparses. Valid field values from the stored data are kept; invalid fields fall back to their
7
+ * corresponding values in `defaultState`. Works recursively for nested ZodObject fields.
8
+ *
9
+ * Non-object schemas (discriminated unions, unions with `.refine()`, etc.) cannot be partially
10
+ * salvaged and return `null`.
11
+ *
12
+ * Consumer-defined `.catch()` wrappers take precedence over the injected salvage catches, since
13
+ * Zod processes catches inside-out.
14
+ *
15
+ * Note: when every field is invalid, the salvaged result will be deeply equal to `defaultState`.
16
+ * Callers should compare the result against `defaultState` to distinguish a genuine partial
17
+ * salvage from a total loss where no stored data survived.
18
+ *
19
+ * @template TState - The type of the stored state.
20
+ * @param schema - Zod schema for validation.
21
+ * @param rawData - The raw deserialized value that failed validation.
22
+ * @param defaultState - The authoritative fallback value; used as per-field catch defaults.
23
+ * @returns {TState | null} The salvaged state, or `null` if salvaging is not possible or also fails.
24
+ */
25
+ export declare const salvageState: <TState>(schema: z.ZodType<TState>, rawData: unknown, defaultState: TState) => TState | null;
@@ -0,0 +1,4 @@
1
+ export declare const storageSerializer: {
2
+ readonly serialize: (value: unknown) => string;
3
+ readonly deserialize: (value: string) => unknown;
4
+ };
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Internal envelope that pairs stored data with a schema version number.
4
+ *
5
+ * `__version` and `__data` are **reserved internal keys** — consumer state
6
+ * objects must not include them as top-level keys. The double-underscore
7
+ * prefix is a deliberate signal that these are private implementation details.
8
+ *
9
+ * A runtime warning is emitted (via `writeToStorage`) if reserved keys are
10
+ * detected in the value being written.
11
+ */
12
+ export declare const storageVersionEnvelopeSchema: z.ZodEffects<z.ZodObject<{
13
+ __version: z.ZodNumber;
14
+ __data: z.ZodUnknown;
15
+ }, "strip", z.ZodTypeAny, {
16
+ __version: number;
17
+ __data?: unknown;
18
+ }, {
19
+ __version: number;
20
+ __data?: unknown;
21
+ }>, {
22
+ __version: number;
23
+ __data: unknown;
24
+ }, {
25
+ __version: number;
26
+ __data?: unknown;
27
+ }>;
28
+ export type StorageVersionEnvelope = z.infer<typeof storageVersionEnvelopeSchema>;
29
+ /** Wraps data and version into a versioned storage envelope. */
30
+ export declare const createStorageVersionEnvelope: (data: unknown, version: number) => StorageVersionEnvelope;
@@ -0,0 +1,41 @@
1
+ import type { z } from "zod";
2
+ export type StorageMigration = {
3
+ readonly version: number;
4
+ readonly migrate: (data: unknown) => unknown;
5
+ };
6
+ export type MigrationConfig = {
7
+ readonly version: number;
8
+ readonly steps?: ReadonlyArray<StorageMigration>;
9
+ readonly fromKey?: string;
10
+ };
11
+ export type WebStorageOptions<TState> = {
12
+ /** Must be a stable, non-empty string. Passing an empty string throws during render. Changing from non-empty to empty across renders is unsupported and will throw. */
13
+ readonly key: string;
14
+ readonly defaultState: NoInfer<TState>;
15
+ readonly schema: z.ZodType<TState>;
16
+ readonly migration?: MigrationConfig;
17
+ /**
18
+ * Narrow, opt-in recovery for keys known to hold a plain-text value that was never
19
+ * JSON-encoded (e.g. a legacy value written via `localStorage.setItem` directly, with
20
+ * no `JSON.stringify`). Only enable this for a key that is provably never written by
21
+ * anything other than a plain string — enabling it broadly would let a genuinely
22
+ * corrupted, truncated envelope be silently accepted as a valid string and re-persisted
23
+ * as such on the next write, permanently hiding the corruption. See `readFromStorage`'s
24
+ * docs for the full rationale.
25
+ */
26
+ readonly legacyRawString?: boolean;
27
+ };
28
+ export type WebStorageCallbacks<TState> = {
29
+ readonly onValidationFailed?: (error: unknown) => void;
30
+ readonly onValidationSuccessful?: (data: TState) => void;
31
+ /**
32
+ * Called when schema validation fails but partial state was recovered.
33
+ * Valid fields from the stored value are kept; invalid fields fall back to their
34
+ * corresponding values in `defaultState`. Only fires during reads (initialization
35
+ * and key changes) — never during the write-sync path.
36
+ *
37
+ * @param error - The ZodError describing which fields failed validation.
38
+ * @param salvaged - The recovered state combining valid stored fields and defaultState fallbacks.
39
+ */
40
+ readonly onValidationSalvaged?: (error: z.ZodError, salvaged: TState) => void;
41
+ };
@@ -0,0 +1,22 @@
1
+ import type { z } from "zod";
2
+ import type { WebStorageCallbacks } from "./types";
3
+ type ValidateStateOptions<TState> = {
4
+ readonly state: unknown;
5
+ readonly defaultState: TState;
6
+ readonly schema: z.ZodType<TState>;
7
+ } & WebStorageCallbacks<TState>;
8
+ /**
9
+ * Validates the state using a Zod schema. Returns the parsed state on success,
10
+ * or defaultState on failure (with error logging and optional callbacks).
11
+ *
12
+ * @template TState - The type of the state.
13
+ * @param params - The state, schema, defaultState, and optional callbacks.
14
+ * @param params.state - The raw state to validate.
15
+ * @param params.schema - The Zod schema for validation.
16
+ * @param params.defaultState - The fallback value on failure.
17
+ * @param params.onValidationFailed - Optional error callback.
18
+ * @param params.onValidationSuccessful - Optional success callback.
19
+ * @returns {TState} The validated state or defaultState.
20
+ */
21
+ export declare const validateState: <TState>({ state, schema, onValidationFailed, onValidationSuccessful, defaultState, }: ValidateStateOptions<TState>) => TState;
22
+ export {};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Serializes and writes a value to web storage.
3
+ * When a version is provided, wraps the data in a versioned envelope
4
+ * before serializing so the migration pipeline can detect it on read.
5
+ *
6
+ * @param storage - The Storage instance (localStorage / sessionStorage).
7
+ * @param key - The storage key.
8
+ * @param value - The value to serialize and store.
9
+ * @param version - Optional schema version to stamp onto the stored payload.
10
+ */
11
+ export declare const writeToStorage: (storage: Storage, key: string, value: unknown, version?: number) => void;