@uniweb/core 0.6.1 → 0.7.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -30,8 +30,8 @@
30
30
  "jest": "^29.7.0"
31
31
  },
32
32
  "dependencies": {
33
- "@uniweb/semantic-parser": "1.1.9",
34
- "@uniweb/theming": "0.1.3"
33
+ "@uniweb/theming": "0.1.3",
34
+ "@uniweb/semantic-parser": "1.1.9"
35
35
  },
36
36
  "scripts": {
37
37
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
package/src/datastore.js CHANGED
@@ -1,152 +1,153 @@
1
1
  /**
2
2
  * DataStore
3
3
  *
4
- * Runtime data cache that persists across SPA navigation.
5
- * Deduplicates in-flight fetches so concurrent callers share a single request.
4
+ * Pure keyed cache with in-flight deduplication. Persists across SPA navigation.
6
5
  *
7
- * Core can't import runtime, so the fetcher function is registered at startup
8
- * via registerFetcher().
6
+ * Owned by the Website; accessed only by the FetcherDispatcher (which computes
7
+ * cache keys and runs fetchers) and by build-time / startup preload paths
8
+ * (which write entries keyed by the default cache key so runtime cache probes
9
+ * find them).
10
+ *
11
+ * No knowledge of fetchers, transports, or cascades. Keys are opaque strings.
9
12
  */
10
13
 
11
14
  /**
12
- * Build a stable cache key from a fetch config.
13
- * Only includes fields that affect the response.
15
+ * Default cache-key derivation for a request or fetch config.
16
+ *
17
+ * The framework's default URL fetcher and the build-time preload path both
18
+ * use this key shape. Fetchers with state-dependent requests (e.g., a query
19
+ * slug read from `page.state`) must declare their own `cacheKey(request)`
20
+ * on the fetcher so reactive changes miss the cache and re-fetch.
21
+ *
22
+ * Fields that contribute to the key:
23
+ * - path, url — what resource is being fetched
24
+ * - schema — which entity type the response will be stored under
25
+ * - transform — any per-fetch response unwrap; different transforms
26
+ * of the same endpoint produce different cached data
27
+ * - method (POST) — POST requests may share a URL with GET; don't collide
28
+ * - body (POST) — two POSTs to the same URL with different bodies are
29
+ * different queries; must cache distinctly
14
30
  *
15
- * @param {Object} config
16
- * @returns {string}
31
+ * Post-processing fields like `limit`, `sort`, `filter` are applied after
32
+ * fetch and must not split the cache.
33
+ *
34
+ * @param {Object} request - Normalized request (or fetch config)
35
+ * @returns {string} A stable JSON string usable as a cache-Map key
17
36
  */
18
- function cacheKey(config) {
19
- const { path, url, schema, transform } = config
20
- return JSON.stringify({ path, url, schema, transform })
37
+ export function deriveCacheKey(request) {
38
+ const { path, url, schema, transform } = request || {}
39
+ const method = request?.method && request.method.toUpperCase() !== 'GET'
40
+ ? request.method.toUpperCase()
41
+ : undefined
42
+ const body = method === 'POST' ? request?.body : undefined
43
+ return JSON.stringify({ path, url, schema, transform, method, body })
21
44
  }
22
45
 
23
46
  export default class DataStore {
24
47
  constructor() {
48
+ // key → { data, meta? }
25
49
  this._cache = new Map()
50
+ // key → { promise, signals: Set<AbortSignal> }
26
51
  this._inflight = new Map()
27
- this._fetcher = null
28
- this._transforms = new Map()
52
+ // Notified on every successful `set()`.
29
53
  this._listeners = new Set()
54
+ // Key-scoped listeners: key → Set<Function>
55
+ this._keyedListeners = new Map()
30
56
 
31
57
  Object.seal(this)
32
58
  }
33
59
 
34
60
  /**
35
- * Subscribe to data updates. Returns an unsubscribe function.
36
- * Called by PageRenderer to re-render when dynamic page data arrives.
37
- * @param {Function} fn - Called whenever new data is stored
61
+ * Subscribe to cache updates.
62
+ *
63
+ * Two forms:
64
+ * - `subscribe(fn)` — fires after every successful `set()` (all keys).
65
+ * - `subscribe(key, fn)` — fires only when `set(key, ...)` is called.
66
+ *
67
+ * The global form is useful for debugging / blanket observers. The keyed
68
+ * form is what Layer-3 kit hooks (`useFetched`, `useCacheEntry`) use so
69
+ * a cache write for one request doesn't wake up every subscriber.
70
+ *
71
+ * @param {string|Function} keyOrFn
72
+ * @param {Function} [maybeFn]
38
73
  * @returns {Function} unsubscribe
39
74
  */
40
- onUpdate(fn) {
41
- this._listeners.add(fn)
42
- return () => this._listeners.delete(fn)
43
- }
44
-
45
- /**
46
- * Register the fetcher function (called by runtime at startup).
47
- * @param {Function} fn - (config) => Promise<{ data, error? }>
48
- */
49
- registerFetcher(fn) {
50
- this._fetcher = fn
51
- }
52
-
53
- /**
54
- * Register a named transform function.
55
- * Named transforms are applied after the fetcher returns, before caching.
56
- * @param {string} name - Transform name (e.g. 'profiles')
57
- * @param {Function} fn - (data, config) => transformedData
58
- */
59
- registerTransform(name, fn) {
60
- this._transforms.set(name, fn)
75
+ subscribe(keyOrFn, maybeFn) {
76
+ if (typeof keyOrFn === 'string' && typeof maybeFn === 'function') {
77
+ const key = keyOrFn
78
+ let set = this._keyedListeners.get(key)
79
+ if (!set) {
80
+ set = new Set()
81
+ this._keyedListeners.set(key, set)
82
+ }
83
+ set.add(maybeFn)
84
+ return () => {
85
+ const s = this._keyedListeners.get(key)
86
+ if (!s) return
87
+ s.delete(maybeFn)
88
+ if (s.size === 0) this._keyedListeners.delete(key)
89
+ }
90
+ }
91
+ if (typeof keyOrFn === 'function') {
92
+ this._listeners.add(keyOrFn)
93
+ return () => this._listeners.delete(keyOrFn)
94
+ }
95
+ throw new TypeError('DataStore.subscribe: expected (fn) or (key, fn)')
61
96
  }
62
97
 
63
98
  /**
64
- * Check whether data for this config is cached.
65
- * @param {Object} config - Fetch config
99
+ * Cache presence check.
100
+ *
101
+ * @param {string} key
66
102
  * @returns {boolean}
67
103
  */
68
- has(config) {
69
- return this._cache.has(cacheKey(config))
104
+ has(key) {
105
+ return this._cache.has(key)
70
106
  }
71
107
 
72
108
  /**
73
- * Return cached data, or null on miss.
74
- * @param {Object} config - Fetch config
75
- * @returns {any|null}
109
+ * Cache lookup.
110
+ *
111
+ * @param {string} key
112
+ * @returns {{ data: any, meta?: Object } | null}
76
113
  */
77
- get(config) {
78
- const key = cacheKey(config)
114
+ get(key) {
79
115
  return this._cache.has(key) ? this._cache.get(key) : null
80
116
  }
81
117
 
82
118
  /**
83
- * Store data in the cache.
84
- * @param {Object} config - Fetch config
85
- * @param {any} data
119
+ * Cache store. Fires listeners: first the global ones (all-writes), then
120
+ * any subscribers registered for this specific key.
121
+ *
122
+ * @param {string} key
123
+ * @param {{ data: any, meta?: Object }} entry
86
124
  */
87
- set(config, data) {
88
- this._cache.set(cacheKey(config), data)
89
- this._listeners.forEach((fn) => fn())
125
+ set(key, entry) {
126
+ this._cache.set(key, entry)
127
+ for (const fn of this._listeners) fn()
128
+ const keyed = this._keyedListeners.get(key)
129
+ if (keyed) {
130
+ for (const fn of keyed) fn()
131
+ }
90
132
  }
91
133
 
92
134
  /**
93
- * Fetch data with caching and in-flight deduplication.
94
- *
95
- * - Cache hit: returns immediately.
96
- * - In-flight: returns existing promise (no duplicate request).
97
- * - Miss: calls the registered fetcher, caches the result.
135
+ * In-flight fetch registry used by the dispatcher to dedup concurrent
136
+ * requests and collect abort signals so the underlying fetch is cancelled
137
+ * only when every attached block aborts.
98
138
  *
99
- * @param {Object} config - Fetch config
100
- * @returns {Promise<{ data: any, error?: string }>}
139
+ * @returns {Map<string, { promise: Promise, signals: Set<AbortSignal> }>}
101
140
  */
102
- async fetch(config) {
103
- if (!this._fetcher) {
104
- throw new Error('DataStore: no fetcher registered. Call registerFetcher() first.')
105
- }
106
-
107
- const key = cacheKey(config)
108
-
109
- // Cache hit
110
- if (this._cache.has(key)) {
111
- return { data: this._cache.get(key) }
112
- }
113
-
114
- // In-flight dedup
115
- if (this._inflight.has(key)) {
116
- return this._inflight.get(key)
117
- }
118
-
119
- // Miss — execute fetch
120
- const promise = this._fetcher(config).then((result) => {
121
- this._inflight.delete(key)
122
- let data = result.data
123
- // Apply named transform if registered (dot-path transforms
124
- // are handled by the fetcher itself via getNestedValue)
125
- if (
126
- data !== undefined &&
127
- data !== null &&
128
- config.transform &&
129
- this._transforms.has(config.transform)
130
- ) {
131
- data = this._transforms.get(config.transform)(data, config)
132
- }
133
- if (data !== undefined && data !== null) {
134
- this._cache.set(key, data)
135
- this._listeners.forEach((fn) => fn())
136
- }
137
- return { ...result, data }
138
- })
139
-
140
- this._inflight.set(key, promise)
141
- return promise
141
+ get inflight() {
142
+ return this._inflight
142
143
  }
143
144
 
144
145
  /**
145
- * Flush cache and in-flight map.
146
+ * Flush cache and in-flight map. Listeners are preserved so subscribers
147
+ * that outlive the cache (kit hooks waiting on a key) aren't orphaned.
146
148
  */
147
149
  clear() {
148
150
  this._cache.clear()
149
151
  this._inflight.clear()
150
- this._transforms.clear()
151
152
  }
152
153
  }