@open-mercato/shared 0.6.6-develop.6184.1.b7e55f8d61 → 0.6.6-develop.6198.1.0822f97cc6

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.
@@ -1,2 +1,2 @@
1
- [build:shared] found 224 entry points
1
+ [build:shared] found 225 entry points
2
2
  [build:shared] built successfully
package/AGENTS.md CHANGED
@@ -35,6 +35,7 @@ yarn workspace @open-mercato/shared build
35
35
  | `api/` | When building scoped API payloads | `@open-mercato/shared/lib/api/scoped` |
36
36
  | `auth/` | When you need wildcard-aware feature matching or shared auth helpers | `@open-mercato/shared/lib/auth/featureMatch` |
37
37
  | `boolean/` | When parsing boolean strings from env/query params | `@open-mercato/shared/lib/boolean` |
38
+ | `browser/` | When persisting client UI state to `localStorage` — use the safe wrappers and the versioned-envelope helper instead of raw `localStorage` reads/writes | `@open-mercato/shared/lib/browser/safeLocalStorage`, `@open-mercato/shared/lib/browser/versionedPreference` |
38
39
  | `commands/` | When implementing undo/redo command pattern | `@open-mercato/shared/lib/commands` |
39
40
  | `commands/flush` | When a command mutates entities across multiple phases (scalar + relation syncs) — wraps phases in a single atomic flush | `@open-mercato/shared/lib/commands/flush` — `withAtomicFlush(em, phases, { transaction? })` |
40
41
  | `commands/runCrudCommandWrite` | When a command writes an entity + custom fields + CRUD/index side effects in one logical operation — composes fork → atomic flush → custom-field write → side-effect queue in the only correct order. **Prefer this over composing the primitives by hand for new commands.** | `@open-mercato/shared/lib/commands/runCrudCommandWrite` — `runCrudCommandWrite({ ctx, entityId, action, scope, phases, customFields?, events?, indexer?, sideEffect })` |
@@ -91,6 +92,24 @@ const results = await findWithDecryption(em, 'Entity', filter, { tenantId, organ
91
92
  import { parseBooleanToken, parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
92
93
  ```
93
94
 
95
+ ### Browser Storage — use the shared helpers instead of raw `localStorage`
96
+
97
+ Persisted client UI state MUST go through the shared `browser/` helpers, never raw `window.localStorage` reads/writes scattered per component:
98
+
99
+ ```typescript
100
+ import { readJsonFromLocalStorage, writeJsonToLocalStorage } from '@open-mercato/shared/lib/browser/safeLocalStorage'
101
+ import { readVersionedPreference, writeVersionedPreference, clearVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'
102
+ ```
103
+
104
+ - `safeLocalStorage` — SSR-safe, error-swallowing JSON get/set/remove. Use for raw values.
105
+ - `versionedPreference` — wraps a value in a `{ v, data }` envelope so schema changes can migrate or safely discard stale data. `readVersionedPreference(key, version, isValid, fallback, { legacyIsValid })` validates the envelope, discards version-mismatched or malformed data, and (when `legacyIsValid` is supplied) migrates a pre-envelope bare value forward on the next write. `readVersionedIdSet`/`writeVersionedIdSet` are convenience wrappers for the common "set of ids" shape.
106
+
107
+ **Versioning threshold** — when to reach for `versionedPreference` vs. a raw scalar:
108
+
109
+ - **Trivial scalar flags** (a single boolean/number/string with no schema to evolve, e.g. `om:sidebarCollapsed`, `om:progress:expanded`) MAY stay raw via `safeLocalStorage` (or a plain `'1'`/`'0'`). Add a one-line comment noting the deliberate choice.
110
+ - **Structured values** (objects, records, arrays of objects — anything whose shape can change incompatibly, e.g. a perspective snapshot, a model-picker selection, a sessions cache) MUST use a versioned envelope so a future shape change can migrate or discard old data instead of crashing or silently corrupting state.
111
+ - A slot that already carries its own inline version discriminator (e.g. `{ v, ... }` checked on read) is already migratable and need not be re-wrapped — re-wrapping changes the on-disk format and discards existing user data.
112
+
94
113
  ### i18n — MUST use for all user-facing strings
95
114
 
96
115
  ```typescript
@@ -0,0 +1,33 @@
1
+ import { readJsonFromLocalStorage, writeJsonToLocalStorage, removeLocalStorageKey } from "./safeLocalStorage.js";
2
+ function readVersionedPreference(key, version, isValid, fallback, options) {
3
+ const raw = readJsonFromLocalStorage(key, null);
4
+ if (raw && typeof raw === "object" && !Array.isArray(raw) && "v" in raw && "data" in raw) {
5
+ const envelope = raw;
6
+ return envelope.v === version && isValid(envelope.data) ? envelope.data : fallback;
7
+ }
8
+ if (options?.legacyIsValid && options.legacyIsValid(raw)) return raw;
9
+ return fallback;
10
+ }
11
+ function writeVersionedPreference(key, version, data) {
12
+ writeJsonToLocalStorage(key, { v: version, data });
13
+ }
14
+ function clearVersionedPreference(key) {
15
+ removeLocalStorageKey(key);
16
+ }
17
+ function isStringArray(value) {
18
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
19
+ }
20
+ function readVersionedIdSet(key, version) {
21
+ return new Set(readVersionedPreference(key, version, isStringArray, [], { legacyIsValid: isStringArray }));
22
+ }
23
+ function writeVersionedIdSet(key, version, ids) {
24
+ writeVersionedPreference(key, version, Array.from(ids));
25
+ }
26
+ export {
27
+ clearVersionedPreference,
28
+ readVersionedIdSet,
29
+ readVersionedPreference,
30
+ writeVersionedIdSet,
31
+ writeVersionedPreference
32
+ };
33
+ //# sourceMappingURL=versionedPreference.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/lib/browser/versionedPreference.ts"],
4
+ "sourcesContent": ["import { readJsonFromLocalStorage, writeJsonToLocalStorage, removeLocalStorageKey, type JsonSerializable } from './safeLocalStorage'\n\ntype VersionedEnvelope<T> = { v: number; data: T }\n\nexport function readVersionedPreference<T>(\n key: string,\n version: number,\n isValid: (value: unknown) => value is T,\n fallback: T,\n options?: { legacyIsValid?: (value: unknown) => value is T },\n): T {\n const raw = readJsonFromLocalStorage<JsonSerializable>(key, null)\n if (raw && typeof raw === 'object' && !Array.isArray(raw) && 'v' in raw && 'data' in raw) {\n const envelope = raw as { v?: unknown; data?: unknown }\n return envelope.v === version && isValid(envelope.data) ? envelope.data : fallback\n }\n if (options?.legacyIsValid && options.legacyIsValid(raw)) return raw\n return fallback\n}\n\nexport function writeVersionedPreference<T>(key: string, version: number, data: T): void {\n writeJsonToLocalStorage(key, { v: version, data } satisfies VersionedEnvelope<T>)\n}\n\nexport function clearVersionedPreference(key: string): void {\n removeLocalStorageKey(key)\n}\n\nfunction isStringArray(value: unknown): value is string[] {\n return Array.isArray(value) && value.every((item) => typeof item === 'string')\n}\n\n/** Convenience pair for the common \"set of ids\" preference shape, with legacy bare-array migration. */\nexport function readVersionedIdSet(key: string, version: number): Set<string> {\n return new Set(readVersionedPreference<string[]>(key, version, isStringArray, [], { legacyIsValid: isStringArray }))\n}\n\nexport function writeVersionedIdSet(key: string, version: number, ids: Set<string>): void {\n writeVersionedPreference(key, version, Array.from(ids))\n}\n"],
5
+ "mappings": "AAAA,SAAS,0BAA0B,yBAAyB,6BAAoD;AAIzG,SAAS,wBACd,KACA,SACA,SACA,UACA,SACG;AACH,QAAM,MAAM,yBAA2C,KAAK,IAAI;AAChE,MAAI,OAAO,OAAO,QAAQ,YAAY,CAAC,MAAM,QAAQ,GAAG,KAAK,OAAO,OAAO,UAAU,KAAK;AACxF,UAAM,WAAW;AACjB,WAAO,SAAS,MAAM,WAAW,QAAQ,SAAS,IAAI,IAAI,SAAS,OAAO;AAAA,EAC5E;AACA,MAAI,SAAS,iBAAiB,QAAQ,cAAc,GAAG,EAAG,QAAO;AACjE,SAAO;AACT;AAEO,SAAS,yBAA4B,KAAa,SAAiB,MAAe;AACvF,0BAAwB,KAAK,EAAE,GAAG,SAAS,KAAK,CAAgC;AAClF;AAEO,SAAS,yBAAyB,KAAmB;AAC1D,wBAAsB,GAAG;AAC3B;AAEA,SAAS,cAAc,OAAmC;AACxD,SAAO,MAAM,QAAQ,KAAK,KAAK,MAAM,MAAM,CAAC,SAAS,OAAO,SAAS,QAAQ;AAC/E;AAGO,SAAS,mBAAmB,KAAa,SAA8B;AAC5E,SAAO,IAAI,IAAI,wBAAkC,KAAK,SAAS,eAAe,CAAC,GAAG,EAAE,eAAe,cAAc,CAAC,CAAC;AACrH;AAEO,SAAS,oBAAoB,KAAa,SAAiB,KAAwB;AACxF,2BAAyB,KAAK,SAAS,MAAM,KAAK,GAAG,CAAC;AACxD;",
6
+ "names": []
7
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.6.6-develop.6184.1.b7e55f8d61";
1
+ const APP_VERSION = "0.6.6-develop.6198.1.0822f97cc6";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/version.ts"],
4
- "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6184.1.b7e55f8d61'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6198.1.0822f97cc6'\nexport const appVersion = APP_VERSION\n"],
5
5
  "mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.6.6-develop.6184.1.b7e55f8d61",
3
+ "version": "0.6.6-develop.6198.1.0822f97cc6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -93,7 +93,7 @@
93
93
  "@mikro-orm/core": "^7.1.4",
94
94
  "@mikro-orm/decorators": "^7.1.4",
95
95
  "@mikro-orm/postgresql": "^7.1.4",
96
- "@open-mercato/cache": "0.6.6-develop.6184.1.b7e55f8d61",
96
+ "@open-mercato/cache": "0.6.6-develop.6198.1.0822f97cc6",
97
97
  "dotenv": "^17.4.2",
98
98
  "rate-limiter-flexible": "^11.2.0",
99
99
  "re2js": "2.8.3",
@@ -0,0 +1,81 @@
1
+ /** @jest-environment jsdom */
2
+ import {
3
+ readVersionedPreference,
4
+ writeVersionedPreference,
5
+ clearVersionedPreference,
6
+ readVersionedIdSet,
7
+ writeVersionedIdSet,
8
+ } from '../versionedPreference'
9
+
10
+ function isStringRecord(value: unknown): value is Record<string, string> {
11
+ return !!value && typeof value === 'object' && !Array.isArray(value)
12
+ && Object.values(value as Record<string, unknown>).every((v) => typeof v === 'string')
13
+ }
14
+
15
+ describe('versionedPreference', () => {
16
+ beforeEach(() => {
17
+ localStorage.clear()
18
+ })
19
+
20
+ it('round-trips a value through write then read', () => {
21
+ writeVersionedPreference('test:a', 1, { foo: 'bar' })
22
+ expect(readVersionedPreference('test:a', 1, isStringRecord, {})).toEqual({ foo: 'bar' })
23
+ })
24
+
25
+ it('discards data on version mismatch', () => {
26
+ writeVersionedPreference('test:b', 1, { foo: 'bar' })
27
+ expect(readVersionedPreference('test:b', 2, isStringRecord, {})).toEqual({})
28
+ })
29
+
30
+ it('discards malformed/invalid envelope data', () => {
31
+ localStorage.setItem('test:c', JSON.stringify({ v: 1, data: { foo: 42 } }))
32
+ expect(readVersionedPreference('test:c', 1, isStringRecord, {})).toEqual({})
33
+ })
34
+
35
+ it('migrates a legacy bare (unversioned) value and upgrades it on next write', () => {
36
+ localStorage.setItem('test:d', JSON.stringify({ foo: 'legacy' }))
37
+ const value = readVersionedPreference('test:d', 1, isStringRecord, {}, { legacyIsValid: isStringRecord })
38
+ expect(value).toEqual({ foo: 'legacy' })
39
+
40
+ writeVersionedPreference('test:d', 1, value)
41
+ expect(JSON.parse(localStorage.getItem('test:d')!)).toEqual({ v: 1, data: { foo: 'legacy' } })
42
+ })
43
+
44
+ it('treats a legacy record with a literal "v" key as legacy data, not a malformed envelope', () => {
45
+ localStorage.setItem('test:legacy-v-key', JSON.stringify({ v: 250, other: 300 }))
46
+ function isNumberRecord(value: unknown): value is Record<string, number> {
47
+ return !!value && typeof value === 'object' && !Array.isArray(value)
48
+ && Object.values(value as Record<string, unknown>).every((v) => typeof v === 'number')
49
+ }
50
+ expect(readVersionedPreference('test:legacy-v-key', 1, isNumberRecord, {}, { legacyIsValid: isNumberRecord }))
51
+ .toEqual({ v: 250, other: 300 })
52
+ })
53
+
54
+ it('does not migrate a legacy value when legacyIsValid is not provided', () => {
55
+ localStorage.setItem('test:e', JSON.stringify({ foo: 'legacy' }))
56
+ expect(readVersionedPreference('test:e', 1, isStringRecord, {})).toEqual({})
57
+ })
58
+
59
+ it('clearVersionedPreference removes the key', () => {
60
+ writeVersionedPreference('test:f', 1, { foo: 'bar' })
61
+ clearVersionedPreference('test:f')
62
+ expect(localStorage.getItem('test:f')).toBeNull()
63
+ })
64
+
65
+ describe('readVersionedIdSet / writeVersionedIdSet', () => {
66
+ it('round-trips a set of ids', () => {
67
+ writeVersionedIdSet('test:ids', 1, new Set(['a', 'b']))
68
+ expect(readVersionedIdSet('test:ids', 1)).toEqual(new Set(['a', 'b']))
69
+ })
70
+
71
+ it('migrates a legacy bare string[] array', () => {
72
+ localStorage.setItem('test:ids-legacy', JSON.stringify(['x', 'y']))
73
+ expect(readVersionedIdSet('test:ids-legacy', 1)).toEqual(new Set(['x', 'y']))
74
+ })
75
+
76
+ it('falls back to an empty set for malformed entries', () => {
77
+ localStorage.setItem('test:ids-bad', JSON.stringify({ v: 1, data: [1, 2, 3] }))
78
+ expect(readVersionedIdSet('test:ids-bad', 1)).toEqual(new Set())
79
+ })
80
+ })
81
+ })
@@ -0,0 +1,40 @@
1
+ import { readJsonFromLocalStorage, writeJsonToLocalStorage, removeLocalStorageKey, type JsonSerializable } from './safeLocalStorage'
2
+
3
+ type VersionedEnvelope<T> = { v: number; data: T }
4
+
5
+ export function readVersionedPreference<T>(
6
+ key: string,
7
+ version: number,
8
+ isValid: (value: unknown) => value is T,
9
+ fallback: T,
10
+ options?: { legacyIsValid?: (value: unknown) => value is T },
11
+ ): T {
12
+ const raw = readJsonFromLocalStorage<JsonSerializable>(key, null)
13
+ if (raw && typeof raw === 'object' && !Array.isArray(raw) && 'v' in raw && 'data' in raw) {
14
+ const envelope = raw as { v?: unknown; data?: unknown }
15
+ return envelope.v === version && isValid(envelope.data) ? envelope.data : fallback
16
+ }
17
+ if (options?.legacyIsValid && options.legacyIsValid(raw)) return raw
18
+ return fallback
19
+ }
20
+
21
+ export function writeVersionedPreference<T>(key: string, version: number, data: T): void {
22
+ writeJsonToLocalStorage(key, { v: version, data } satisfies VersionedEnvelope<T>)
23
+ }
24
+
25
+ export function clearVersionedPreference(key: string): void {
26
+ removeLocalStorageKey(key)
27
+ }
28
+
29
+ function isStringArray(value: unknown): value is string[] {
30
+ return Array.isArray(value) && value.every((item) => typeof item === 'string')
31
+ }
32
+
33
+ /** Convenience pair for the common "set of ids" preference shape, with legacy bare-array migration. */
34
+ export function readVersionedIdSet(key: string, version: number): Set<string> {
35
+ return new Set(readVersionedPreference<string[]>(key, version, isStringArray, [], { legacyIsValid: isStringArray }))
36
+ }
37
+
38
+ export function writeVersionedIdSet(key: string, version: number, ids: Set<string>): void {
39
+ writeVersionedPreference(key, version, Array.from(ids))
40
+ }