@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 +429 -2
- package/index.esm.js +423 -3
- package/package.json +3 -1
- package/src/index.d.ts +6 -0
- package/src/localStorage/localStorageAccessors.d.ts +60 -0
- package/src/localStorage/readFromStorage.d.ts +53 -0
- package/src/localStorage/runMigrations.d.ts +13 -0
- package/src/localStorage/salvageState.d.ts +25 -0
- package/src/localStorage/storageSerializer.d.ts +4 -0
- package/src/localStorage/storageVersionEnvelope.d.ts +30 -0
- package/src/localStorage/types.d.ts +41 -0
- package/src/localStorage/validateState.d.ts +22 -0
- package/src/localStorage/writeToStorage.d.ts +11 -0
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(
|
|
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(
|
|
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.
|
|
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,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;
|