@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +19 -0
- package/dist/lib/browser/versionedPreference.js +33 -0
- package/dist/lib/browser/versionedPreference.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/browser/__tests__/versionedPreference.test.ts +81 -0
- package/src/lib/browser/versionedPreference.ts +40 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:shared] found
|
|
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
|
+
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|