@happyvertical/smrt-web 0.37.10
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/AGENTS.md +56 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/dist/index.d.ts +294 -0
- package/dist/index.js +187 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @happyvertical/smrt-web
|
|
2
|
+
|
|
3
|
+
Browser client data runtime — the web twin of `smrt-mobile`. Materializes the
|
|
4
|
+
manifest-generated web collection definitions (`@happyvertical/smrt-virt-web`)
|
|
5
|
+
as cached, reactive collections over the generated SMRT REST surface.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
Wraps a client-data engine (currently TanStack DB) so consumers get
|
|
10
|
+
stale-while-revalidate reads, concurrent-request dedup, and optimistic
|
|
11
|
+
mutations without hand-wiring cache keys or fetch/state.
|
|
12
|
+
|
|
13
|
+
- `createSmrtCollection(definition, options)` — typed collection factory over a
|
|
14
|
+
generated `@happyvertical/smrt-virt-web` definition. Stale-while-revalidate
|
|
15
|
+
reads (`staleTimeMs`, default 30s); N concurrent identical reads coalesce into
|
|
16
|
+
one request; optimistic inserts persist through the REST surface and roll back
|
|
17
|
+
automatically on server error.
|
|
18
|
+
- `createSmrtWebClient()` — an opaque shared-cache handle. Pass one app-wide
|
|
19
|
+
instance so collections share a cache and deduplicate requests.
|
|
20
|
+
- `createDefinitionFetchers(definition, basePath, fetchFn)` — CRUD fetchers
|
|
21
|
+
derived from the generated definition (same URL scheme as the generated
|
|
22
|
+
client, but HTTP error statuses reject instead of resolving).
|
|
23
|
+
- `unwrapListResult` / `unwrapItemResult` — normalize generated-client payloads
|
|
24
|
+
(`T[]`, `{ data }` envelopes, `{ error }` bodies → thrown
|
|
25
|
+
`SmrtWebRequestError`).
|
|
26
|
+
|
|
27
|
+
## The engine-absorption boundary (ratified conditions, #1761)
|
|
28
|
+
|
|
29
|
+
1. **No engine types in the public API.** `@tanstack/*` types must never appear
|
|
30
|
+
on this package's public surface. Collections are handed back as the
|
|
31
|
+
SMRT-owned `SmrtWebCollection`, and the shared cache as the opaque
|
|
32
|
+
`SmrtWebClient`. Enforced by `scripts/check-smrt-web-engine-boundary.mjs`,
|
|
33
|
+
run at the end of `build` (fails the build on any `@tanstack/` reference in
|
|
34
|
+
an emitted `.d.ts`).
|
|
35
|
+
2. **Framework-agnostic core.** This entry imports no UI framework and must not
|
|
36
|
+
import `@tanstack/svelte-db` (it ships only a `svelte` export condition,
|
|
37
|
+
unresolvable outside Svelte bundlers). Svelte live-query bindings ship in a
|
|
38
|
+
separate entry/package.
|
|
39
|
+
3. **Code-split / lazy.** The engine (~76 kB gzip) must never load on public /
|
|
40
|
+
smrt-sites pages. Consumers load the runtime only on surfaces that use live
|
|
41
|
+
collections.
|
|
42
|
+
|
|
43
|
+
## Conventions
|
|
44
|
+
|
|
45
|
+
- **No inter-smrt dependencies** — depends only on TanStack packages
|
|
46
|
+
(dependency-DAG guardrails). Definitions and fetchers arrive as arguments.
|
|
47
|
+
- Rows are plain DTOs with a required `id`; optimistic inserts use
|
|
48
|
+
`newLocalId()` — the generated REST layer strips client ids on create
|
|
49
|
+
(#1540), so the post-persist refetch reconciles server-assigned ids.
|
|
50
|
+
- To swap the engine, reimplement the SMRT-owned public types over a different
|
|
51
|
+
backend; the boundary guard keeps consumers insulated from the change.
|
|
52
|
+
|
|
53
|
+
## Reference consumer
|
|
54
|
+
|
|
55
|
+
`packages/products` consumes the runtime as its reference store across npm,
|
|
56
|
+
federation, and standalone modes (see the smrt-web track, PRD #1755).
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright <2025> <Happy Vertical Corporation>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build CRUD fetchers from a generated collection definition — the same URL
|
|
3
|
+
* scheme and payload handling as the generated REST client
|
|
4
|
+
* (`basePath + endpoint`), with one improvement: HTTP error statuses reject
|
|
5
|
+
* with the server's `{ error }` body instead of resolving with it.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createDefinitionFetchers(definition: SmrtWebCollectionDefinition<object>, basePath?: string, fetchFn?: typeof fetch): SmrtCrudFetchers;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a typed client collection over a generated SMRT collection definition
|
|
11
|
+
* and the matching generated REST client fetchers.
|
|
12
|
+
*
|
|
13
|
+
* Reads: stale-while-revalidate. The first subscriber triggers a fetch;
|
|
14
|
+
* re-subscribing within `staleTimeMs` serves local data with no request. N
|
|
15
|
+
* concurrent identical reads coalesce into one network request.
|
|
16
|
+
*
|
|
17
|
+
* Writes: `collection.insert({ ...data, id: newLocalId() })` applies instantly,
|
|
18
|
+
* persists through `fetchers.create()` (the temp id is stripped — the server
|
|
19
|
+
* assigns the real one), then refetches to reconcile. A failed create rejects
|
|
20
|
+
* the transaction and the optimistic row rolls back automatically.
|
|
21
|
+
*
|
|
22
|
+
* Relationship-derived invalidation: once a create/update/delete has persisted,
|
|
23
|
+
* the query caches of this collection AND the collections named by
|
|
24
|
+
* `definition.relationships` (manifest-derived edges) are invalidated, so
|
|
25
|
+
* dependent views refetch. Reaching OTHER collections requires them to share
|
|
26
|
+
* this collection's `client` (see {@link createSmrtWebClient}); with a private
|
|
27
|
+
* client only this collection refetches.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createSmrtCollection<TData extends object>(definition: SmrtWebCollectionDefinition<TData>, options: CreateSmrtCollectionOptions): SmrtWebCollection<TData>;
|
|
30
|
+
|
|
31
|
+
export declare interface CreateSmrtCollectionOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Generated REST client surface for this collection, e.g.
|
|
34
|
+
* `createClient('/api/v1').products` from the virt-client module. When
|
|
35
|
+
* omitted, fetchers are derived from the definition's endpoint and `basePath`
|
|
36
|
+
* with the same URL scheme and payload shapes the generated client uses.
|
|
37
|
+
*/
|
|
38
|
+
fetchers?: SmrtCrudFetchers;
|
|
39
|
+
/** API base path for definition-derived fetchers (default `/api/v1`). */
|
|
40
|
+
basePath?: string;
|
|
41
|
+
/** Fetch implementation override (tests, SSR). Defaults to global fetch. */
|
|
42
|
+
fetchFn?: typeof fetch;
|
|
43
|
+
/**
|
|
44
|
+
* Shared cache handle from {@link createSmrtWebClient}. Pass one app-wide
|
|
45
|
+
* instance so collections share a cache and deduplicate requests; a private
|
|
46
|
+
* cache is created when omitted.
|
|
47
|
+
*/
|
|
48
|
+
client?: SmrtWebClient;
|
|
49
|
+
/**
|
|
50
|
+
* Cache namespace for this collection's reads. Fold a backend / tenant /
|
|
51
|
+
* preview discriminator in here when the SAME generated collection is
|
|
52
|
+
* materialized against DIFFERENT backends while sharing one {@link client} —
|
|
53
|
+
* without it those reads share a cache key and could serve one backend's rows
|
|
54
|
+
* for the other for the whole `staleTimeMs` window. Omit for the common
|
|
55
|
+
* single-backend case.
|
|
56
|
+
*/
|
|
57
|
+
scope?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Stale-while-revalidate window in milliseconds (default 30s): reads within
|
|
60
|
+
* the window are served from the local collection without a network request;
|
|
61
|
+
* the first read after it revalidates in the background.
|
|
62
|
+
*/
|
|
63
|
+
staleTimeMs?: number;
|
|
64
|
+
/** Retry failed loads (default false: fail fast, surface errors). */
|
|
65
|
+
retry?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a shared client-cache handle. Pass the returned handle as
|
|
70
|
+
* {@link CreateSmrtCollectionOptions.client} to every collection that should
|
|
71
|
+
* share a cache and deduplicate requests app-wide.
|
|
72
|
+
*/
|
|
73
|
+
export declare function createSmrtWebClient(): SmrtWebClient;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Retrieve the underlying engine collection backing a handle — an advanced
|
|
77
|
+
* bridge for trusted framework bindings (e.g. the smrt-svelte live-query
|
|
78
|
+
* binding), which must feed the engine collection to the query builder. Returns
|
|
79
|
+
* `unknown` so no engine type crosses the boundary; callers cast. Throws for a
|
|
80
|
+
* handle not produced by {@link createSmrtCollection}. Not needed for normal
|
|
81
|
+
* use.
|
|
82
|
+
*/
|
|
83
|
+
export declare function getEngineCollection<TData extends object>(handle: SmrtWebCollection<TData>): unknown;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Generate a client-local id for optimistic inserts. The generated REST layer
|
|
87
|
+
* strips client-supplied ids on create (mass-assignment guard #1540), so this
|
|
88
|
+
* id only identifies the optimistic row until the post-persist refetch swaps in
|
|
89
|
+
* the server-assigned row.
|
|
90
|
+
*/
|
|
91
|
+
export declare function newLocalId(): string;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The per-collection CRUD surface of the generated REST client
|
|
95
|
+
* (`createClient(basePath).<collection>` from `@happyvertical/smrt-virt-client`).
|
|
96
|
+
*
|
|
97
|
+
* Return types are `unknown` on purpose: generated fetchers resolve with
|
|
98
|
+
* whatever the server sent, so this package normalizes and validates payloads
|
|
99
|
+
* centrally — see {@link unwrapListResult} / {@link unwrapItemResult}.
|
|
100
|
+
*/
|
|
101
|
+
export declare interface SmrtCrudFetchers {
|
|
102
|
+
list(params?: Record<string, unknown>): Promise<unknown>;
|
|
103
|
+
get?(id: string): Promise<unknown>;
|
|
104
|
+
create(data: Record<string, unknown>): Promise<unknown>;
|
|
105
|
+
update?(id: string, data: Record<string, unknown>): Promise<unknown>;
|
|
106
|
+
delete?(id: string): Promise<unknown>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Opaque handle to the shared client cache / request-dedup layer. Create one
|
|
111
|
+
* with {@link createSmrtWebClient} and pass the SAME instance to every
|
|
112
|
+
* collection that should share a cache and deduplicate in-flight requests.
|
|
113
|
+
*
|
|
114
|
+
* The engine (currently a TanStack Query client) is intentionally hidden behind
|
|
115
|
+
* this brand so it stays swappable — do not depend on its concrete shape.
|
|
116
|
+
*/
|
|
117
|
+
export declare interface SmrtWebClient {
|
|
118
|
+
/** Phantom brand — this handle wraps the hidden client-cache engine. */
|
|
119
|
+
readonly __smrtWebClient: 'SmrtWebClient';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A live, cached collection of plain-DTO rows — the SMRT-owned public contract
|
|
124
|
+
* over the client-data engine. Exposes only the committed surface; the engine's
|
|
125
|
+
* own type is never named here so it stays swappable.
|
|
126
|
+
*/
|
|
127
|
+
export declare interface SmrtWebCollection<TData extends object> {
|
|
128
|
+
/** All rows currently in the collection (plain DTOs, insertion order). */
|
|
129
|
+
readonly toArray: ReadonlyArray<SmrtWebRow<TData>>;
|
|
130
|
+
/** Number of rows currently in the collection. */
|
|
131
|
+
readonly size: number;
|
|
132
|
+
/** True when a row with `key` is present. */
|
|
133
|
+
has(key: string): boolean;
|
|
134
|
+
/** The row with `key`, or `undefined`. */
|
|
135
|
+
get(key: string): SmrtWebRow<TData> | undefined;
|
|
136
|
+
/** Resolve once the first load has completed. */
|
|
137
|
+
preload(): Promise<void>;
|
|
138
|
+
/** Tear down subscriptions and cached state. */
|
|
139
|
+
cleanup(): Promise<void>;
|
|
140
|
+
/** Subscribe to change notifications; returns a detach handle. */
|
|
141
|
+
subscribeChanges(callback: (changes: unknown) => void): SmrtWebSubscription;
|
|
142
|
+
/**
|
|
143
|
+
* Optimistically insert a row and persist it through the create fetcher. The
|
|
144
|
+
* row is visible synchronously; the returned transaction settles on the
|
|
145
|
+
* server outcome (see {@link SmrtWebTransaction}).
|
|
146
|
+
*/
|
|
147
|
+
insert(row: SmrtWebRow<TData>): SmrtWebTransaction;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One generated collection definition: everything needed to construct a client
|
|
152
|
+
* collection over the generated REST surface. The `_row` property is a phantom
|
|
153
|
+
* type carrier threaded through codegen — it never exists at runtime, it only
|
|
154
|
+
* lets factories infer the row type from a definition.
|
|
155
|
+
*/
|
|
156
|
+
export declare interface SmrtWebCollectionDefinition<TData extends object = object> {
|
|
157
|
+
/** REST collection name (e.g. `products`). */
|
|
158
|
+
name: string;
|
|
159
|
+
/** Source class name (e.g. `Product`). */
|
|
160
|
+
className: string;
|
|
161
|
+
/** Path under the API base path (e.g. `/products`). */
|
|
162
|
+
endpoint: string;
|
|
163
|
+
/** Primary key field name (`id` for SmrtObject). */
|
|
164
|
+
idField: string;
|
|
165
|
+
/** CRUD + custom actions exposed by the api decorator config. */
|
|
166
|
+
actions: string[];
|
|
167
|
+
/** Persisted field metadata keyed by field name. */
|
|
168
|
+
fields: Record<string, SmrtWebFieldDefinition>;
|
|
169
|
+
/**
|
|
170
|
+
* Manifest-derived relationship edges to sibling REST collections. Drives
|
|
171
|
+
* relationship-derived cache invalidation: a settled mutation on this
|
|
172
|
+
* collection invalidates the caches of the collections these edges name.
|
|
173
|
+
* Optional so hand-built definitions (older codegen, tests) still satisfy the
|
|
174
|
+
* type; a missing value means "no derived edges".
|
|
175
|
+
*/
|
|
176
|
+
relationships?: SmrtWebRelationship[];
|
|
177
|
+
/** Phantom row-type carrier — never present at runtime. */
|
|
178
|
+
_row?: TData;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @happyvertical/smrt-web — browser client data runtime (#1761).
|
|
183
|
+
*
|
|
184
|
+
* A typed collection factory that materializes the manifest-generated web
|
|
185
|
+
* collection definitions (`@happyvertical/smrt-virt-web`) as cached, reactive
|
|
186
|
+
* collections over the generated SMRT REST surface.
|
|
187
|
+
*
|
|
188
|
+
* This package is the **engine-absorption boundary**: the client-data engine
|
|
189
|
+
* (currently TanStack DB) is an implementation detail held entirely inside
|
|
190
|
+
* this module. Its types never appear on the public API — collections are
|
|
191
|
+
* handed back as the SMRT-owned {@link SmrtWebCollection}, and the shared cache
|
|
192
|
+
* as the opaque {@link SmrtWebClient} — so the engine stays swappable without a
|
|
193
|
+
* consumer-visible break. Consumers never import `@tanstack/*` directly.
|
|
194
|
+
*
|
|
195
|
+
* Framework-agnostic by construction: this entry imports no UI framework.
|
|
196
|
+
* Svelte live-query bindings ship separately (see PRD #1755) so this core never
|
|
197
|
+
* pulls the Svelte-only `@tanstack/svelte-db` export condition.
|
|
198
|
+
*
|
|
199
|
+
* Scope of this slice:
|
|
200
|
+
* - stale-while-revalidate reads (a `staleTimeMs` window, background revalidation)
|
|
201
|
+
* - concurrent-read dedup (one network request per in-flight collection load)
|
|
202
|
+
* - optimistic create that persists through the generated REST surface and
|
|
203
|
+
* rolls back automatically when the server errors
|
|
204
|
+
* - relationship-derived invalidation (#1761): a settled mutation invalidates
|
|
205
|
+
* the caches of the collections related to the mutated one, with the edges
|
|
206
|
+
* derived from the manifest (`definition.relationships`) — no hand-wired
|
|
207
|
+
* cache keys. Cross-collection reach requires a shared client from
|
|
208
|
+
* {@link createSmrtWebClient}; with a private client only the mutated
|
|
209
|
+
* collection refetches.
|
|
210
|
+
*
|
|
211
|
+
* Deliberately NOT here yet (see PRD #1755): SvelteKit hydration seeding,
|
|
212
|
+
* offline outbox, SSE invalidation, persistence, version awareness.
|
|
213
|
+
*/
|
|
214
|
+
/**
|
|
215
|
+
* Field metadata emitted per column by the `@happyvertical/smrt-virt-web`
|
|
216
|
+
* virtual module (generated from the package manifest).
|
|
217
|
+
*/
|
|
218
|
+
export declare interface SmrtWebFieldDefinition {
|
|
219
|
+
type: string;
|
|
220
|
+
required?: boolean;
|
|
221
|
+
default?: unknown;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A manifest-derived edge from this collection to a sibling REST collection,
|
|
226
|
+
* emitted by the `@happyvertical/smrt-virt-web` virtual module. When a mutation
|
|
227
|
+
* on this collection settles, the caches of the collections named by these
|
|
228
|
+
* edges are invalidated (relationship-derived invalidation, #1761), so a
|
|
229
|
+
* dependent view refetches without any hand-wired cache key.
|
|
230
|
+
*
|
|
231
|
+
* SMRT-owned data — no client-engine (`@tanstack/*`) type appears here, so it
|
|
232
|
+
* stays inside the engine-absorption boundary.
|
|
233
|
+
*/
|
|
234
|
+
export declare interface SmrtWebRelationship {
|
|
235
|
+
/** The declaring field carrying the relationship (e.g. `groupId`, `items`). */
|
|
236
|
+
field: string;
|
|
237
|
+
/** The relationship kind, mirroring the manifest field type. */
|
|
238
|
+
kind: SmrtWebRelationshipKind;
|
|
239
|
+
/** REST collection name the edge resolves to (e.g. `ad_groups`). */
|
|
240
|
+
relatedCollection: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The relationship kinds a generated web collection edge can describe. */
|
|
244
|
+
export declare type SmrtWebRelationshipKind = 'foreignKey' | 'crossPackageRef' | 'oneToMany' | 'manyToMany';
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Raised when a generated-client call resolved with an error payload
|
|
248
|
+
* (`{ error: string }` from the generated REST routes) or an unexpected shape.
|
|
249
|
+
* Thrown inside a mutation handler, this triggers the automatic rollback of
|
|
250
|
+
* optimistic state.
|
|
251
|
+
*/
|
|
252
|
+
export declare class SmrtWebRequestError extends Error {
|
|
253
|
+
readonly payload: unknown;
|
|
254
|
+
constructor(message: string, payload?: unknown);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A row as stored in the client collection: the DTO plus a required key. */
|
|
258
|
+
export declare type SmrtWebRow<TData extends object> = TData & {
|
|
259
|
+
id: string;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/** A change-subscription handle. Call {@link unsubscribe} to detach. */
|
|
263
|
+
export declare interface SmrtWebSubscription {
|
|
264
|
+
unsubscribe(): void;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A pending optimistic mutation. Await {@link isPersisted} to observe the
|
|
269
|
+
* server outcome: it resolves once the write has been persisted through the
|
|
270
|
+
* REST surface, and rejects (rolling the optimistic state back) on error.
|
|
271
|
+
*/
|
|
272
|
+
export declare interface SmrtWebTransaction {
|
|
273
|
+
readonly isPersisted: {
|
|
274
|
+
readonly promise: Promise<unknown>;
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Normalize a generated-client item result (create/update) to a row.
|
|
280
|
+
* `{ error }` payloads become failures — inside mutation handlers this is what
|
|
281
|
+
* makes optimistic state roll back.
|
|
282
|
+
*/
|
|
283
|
+
export declare function unwrapItemResult(result: unknown, context: string): Record<string, unknown>;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Normalize a generated-client list result to an array of rows.
|
|
287
|
+
*
|
|
288
|
+
* The generated REST routes return a bare JSON array; `{ error }` payloads are
|
|
289
|
+
* surfaced as failures. The `{ data: [...] }` envelope is tolerated for
|
|
290
|
+
* ApiResponse-shaped clients (e.g. a mock client).
|
|
291
|
+
*/
|
|
292
|
+
export declare function unwrapListResult(result: unknown, collectionName: string): Array<Record<string, unknown>>;
|
|
293
|
+
|
|
294
|
+
export { }
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { createCollection } from "@tanstack/db";
|
|
2
|
+
import { QueryClient } from "@tanstack/query-core";
|
|
3
|
+
import { queryCollectionOptions } from "@tanstack/query-db-collection";
|
|
4
|
+
//#region src/index.ts
|
|
5
|
+
var SmrtWebRequestError = class extends Error {
|
|
6
|
+
payload;
|
|
7
|
+
constructor(message, payload) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "SmrtWebRequestError";
|
|
10
|
+
this.payload = payload;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
function unwrapListResult(result, collectionName) {
|
|
14
|
+
if (Array.isArray(result)) return result;
|
|
15
|
+
if (result && typeof result === "object") {
|
|
16
|
+
const record = result;
|
|
17
|
+
if (typeof record.error === "string") throw new SmrtWebRequestError(`[smrt-web] list(${collectionName}) failed: ${record.error}`, result);
|
|
18
|
+
if (Array.isArray(record.data)) return record.data;
|
|
19
|
+
}
|
|
20
|
+
throw new SmrtWebRequestError(`[smrt-web] list(${collectionName}) returned an unexpected payload shape`, result);
|
|
21
|
+
}
|
|
22
|
+
function unwrapItemResult(result, context) {
|
|
23
|
+
if (result && typeof result === "object" && !Array.isArray(result)) {
|
|
24
|
+
const record = result;
|
|
25
|
+
if (typeof record.error === "string") throw new SmrtWebRequestError(`[smrt-web] ${context} failed: ${record.error}`, result);
|
|
26
|
+
if (record.data && typeof record.data === "object" && !Array.isArray(record.data)) return record.data;
|
|
27
|
+
return record;
|
|
28
|
+
}
|
|
29
|
+
throw new SmrtWebRequestError(`[smrt-web] ${context} returned an unexpected payload shape`, result);
|
|
30
|
+
}
|
|
31
|
+
function createDefinitionFetchers(definition, basePath = "/api/v1", fetchFn = (...args) => globalThis.fetch(...args)) {
|
|
32
|
+
const collectionUrl = `${basePath}${definition.endpoint}`;
|
|
33
|
+
const headers = { "Content-Type": "application/json" };
|
|
34
|
+
const parse = async (response) => {
|
|
35
|
+
const payload = await response.json().catch(() => null);
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
const message = payload && typeof payload === "object" && typeof payload.error === "string" ? String(payload.error) : `HTTP ${response.status}`;
|
|
38
|
+
throw new SmrtWebRequestError(`[smrt-web] ${definition.name} request failed: ${message}`, payload);
|
|
39
|
+
}
|
|
40
|
+
return payload;
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
list: async () => parse(await fetchFn(collectionUrl, { headers })),
|
|
44
|
+
get: async (id) => parse(await fetchFn(`${collectionUrl}/${id}`, { headers })),
|
|
45
|
+
create: async (data) => parse(await fetchFn(collectionUrl, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers,
|
|
48
|
+
body: JSON.stringify(data)
|
|
49
|
+
})),
|
|
50
|
+
update: async (id, data) => parse(await fetchFn(`${collectionUrl}/${id}`, {
|
|
51
|
+
method: "PUT",
|
|
52
|
+
headers,
|
|
53
|
+
body: JSON.stringify(data)
|
|
54
|
+
})),
|
|
55
|
+
delete: async (id) => {
|
|
56
|
+
const response = await fetchFn(`${collectionUrl}/${id}`, {
|
|
57
|
+
method: "DELETE",
|
|
58
|
+
headers
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok) throw new SmrtWebRequestError(`[smrt-web] delete(${definition.name}) failed: HTTP ${response.status}`);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function newLocalId() {
|
|
66
|
+
const cryptoRef = globalThis.crypto;
|
|
67
|
+
if (cryptoRef?.randomUUID) return cryptoRef.randomUUID();
|
|
68
|
+
return `local-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
69
|
+
}
|
|
70
|
+
function createSmrtWebClient() {
|
|
71
|
+
return {
|
|
72
|
+
__smrtWebClient: "SmrtWebClient",
|
|
73
|
+
queryClient: new QueryClient()
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function resolveQueryClient(client) {
|
|
77
|
+
if (!client) return new QueryClient();
|
|
78
|
+
const engine = client;
|
|
79
|
+
if (engine.__smrtWebClient !== "SmrtWebClient" || !engine.queryClient) throw new SmrtWebRequestError("[smrt-web] options.client must be a handle from createSmrtWebClient()");
|
|
80
|
+
return engine.queryClient;
|
|
81
|
+
}
|
|
82
|
+
function toPlainRow(row) {
|
|
83
|
+
const plain = {};
|
|
84
|
+
for (const [key, value] of Object.entries(row)) if (key.charCodeAt(0) !== 36) plain[key] = value;
|
|
85
|
+
return plain;
|
|
86
|
+
}
|
|
87
|
+
function projectChanges(changes) {
|
|
88
|
+
if (!Array.isArray(changes)) return changes;
|
|
89
|
+
return changes.map((change) => {
|
|
90
|
+
if (!change || typeof change !== "object") return change;
|
|
91
|
+
const record = change;
|
|
92
|
+
const projected = { ...record };
|
|
93
|
+
if (record.value && typeof record.value === "object") projected.value = toPlainRow(record.value);
|
|
94
|
+
if (record.previousValue && typeof record.previousValue === "object") projected.previousValue = toPlainRow(record.previousValue);
|
|
95
|
+
return projected;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
var engineCollections = /* @__PURE__ */ new WeakMap();
|
|
99
|
+
function getEngineCollection(handle) {
|
|
100
|
+
const engine = engineCollections.get(handle);
|
|
101
|
+
if (engine === void 0) throw new SmrtWebRequestError("[smrt-web] getEngineCollection: not a smrt-web collection handle");
|
|
102
|
+
return engine;
|
|
103
|
+
}
|
|
104
|
+
function createSmrtCollection(definition, options) {
|
|
105
|
+
const { staleTimeMs = 3e4, retry = false, scope } = options;
|
|
106
|
+
const fetchers = options.fetchers ?? createDefinitionFetchers(definition, options.basePath, options.fetchFn);
|
|
107
|
+
const queryClient = resolveQueryClient(options.client);
|
|
108
|
+
const idField = definition.idField || "id";
|
|
109
|
+
const cacheId = scope ? `smrt:${scope}:${definition.name}` : `smrt:${definition.name}`;
|
|
110
|
+
const queryKey = scope ? [
|
|
111
|
+
"smrt",
|
|
112
|
+
scope,
|
|
113
|
+
definition.name
|
|
114
|
+
] : ["smrt", definition.name];
|
|
115
|
+
const invalidationTargets = /* @__PURE__ */ new Set([definition.name]);
|
|
116
|
+
for (const relationship of definition.relationships ?? []) invalidationTargets.add(relationship.relatedCollection);
|
|
117
|
+
const invalidateRelated = () => {
|
|
118
|
+
queryClient.invalidateQueries({ predicate: (query) => {
|
|
119
|
+
const key = query.queryKey;
|
|
120
|
+
if (!Array.isArray(key) || key.length === 0) return false;
|
|
121
|
+
const collectionSegment = key[key.length - 1];
|
|
122
|
+
return typeof collectionSegment === "string" && invalidationTargets.has(collectionSegment);
|
|
123
|
+
} });
|
|
124
|
+
};
|
|
125
|
+
const collection = createCollection(queryCollectionOptions({
|
|
126
|
+
id: cacheId,
|
|
127
|
+
queryKey,
|
|
128
|
+
queryClient,
|
|
129
|
+
staleTime: staleTimeMs,
|
|
130
|
+
retry,
|
|
131
|
+
queryFn: async () => unwrapListResult(await fetchers.list(), definition.name),
|
|
132
|
+
getKey: (row) => String(row[idField]),
|
|
133
|
+
onInsert: async ({ transaction }) => {
|
|
134
|
+
for (const mutation of transaction.mutations) {
|
|
135
|
+
const { [idField]: _localId, ...data } = mutation.modified;
|
|
136
|
+
unwrapItemResult(await fetchers.create(data), `create(${definition.name})`);
|
|
137
|
+
}
|
|
138
|
+
invalidateRelated();
|
|
139
|
+
},
|
|
140
|
+
onUpdate: fetchers.update ? async ({ transaction }) => {
|
|
141
|
+
for (const mutation of transaction.mutations) {
|
|
142
|
+
const key = String(mutation.key);
|
|
143
|
+
const changes = mutation.changes;
|
|
144
|
+
unwrapItemResult(await fetchers.update(key, changes), `update(${definition.name})`);
|
|
145
|
+
}
|
|
146
|
+
invalidateRelated();
|
|
147
|
+
} : void 0,
|
|
148
|
+
onDelete: fetchers.delete ? async ({ transaction }) => {
|
|
149
|
+
for (const mutation of transaction.mutations) await fetchers.delete(String(mutation.key));
|
|
150
|
+
invalidateRelated();
|
|
151
|
+
} : void 0
|
|
152
|
+
}));
|
|
153
|
+
const handle = {
|
|
154
|
+
get toArray() {
|
|
155
|
+
return collection.toArray.map((row) => toPlainRow(row));
|
|
156
|
+
},
|
|
157
|
+
get size() {
|
|
158
|
+
return collection.size;
|
|
159
|
+
},
|
|
160
|
+
has(key) {
|
|
161
|
+
return collection.has(key);
|
|
162
|
+
},
|
|
163
|
+
get(key) {
|
|
164
|
+
const row = collection.get(key);
|
|
165
|
+
return row === void 0 ? void 0 : toPlainRow(row);
|
|
166
|
+
},
|
|
167
|
+
preload() {
|
|
168
|
+
return collection.preload();
|
|
169
|
+
},
|
|
170
|
+
cleanup() {
|
|
171
|
+
return collection.cleanup();
|
|
172
|
+
},
|
|
173
|
+
subscribeChanges(callback) {
|
|
174
|
+
const subscription = collection.subscribeChanges((changes) => callback(projectChanges(changes)));
|
|
175
|
+
return { unsubscribe: () => subscription.unsubscribe() };
|
|
176
|
+
},
|
|
177
|
+
insert(row) {
|
|
178
|
+
return collection.insert(row);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
engineCollections.set(handle, collection);
|
|
182
|
+
return handle;
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
export { SmrtWebRequestError, createDefinitionFetchers, createSmrtCollection, createSmrtWebClient, getEngineCollection, newLocalId, unwrapItemResult, unwrapListResult };
|
|
186
|
+
|
|
187
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * @happyvertical/smrt-web — browser client data runtime (#1761).\n *\n * A typed collection factory that materializes the manifest-generated web\n * collection definitions (`@happyvertical/smrt-virt-web`) as cached, reactive\n * collections over the generated SMRT REST surface.\n *\n * This package is the **engine-absorption boundary**: the client-data engine\n * (currently TanStack DB) is an implementation detail held entirely inside\n * this module. Its types never appear on the public API — collections are\n * handed back as the SMRT-owned {@link SmrtWebCollection}, and the shared cache\n * as the opaque {@link SmrtWebClient} — so the engine stays swappable without a\n * consumer-visible break. Consumers never import `@tanstack/*` directly.\n *\n * Framework-agnostic by construction: this entry imports no UI framework.\n * Svelte live-query bindings ship separately (see PRD #1755) so this core never\n * pulls the Svelte-only `@tanstack/svelte-db` export condition.\n *\n * Scope of this slice:\n * - stale-while-revalidate reads (a `staleTimeMs` window, background revalidation)\n * - concurrent-read dedup (one network request per in-flight collection load)\n * - optimistic create that persists through the generated REST surface and\n * rolls back automatically when the server errors\n * - relationship-derived invalidation (#1761): a settled mutation invalidates\n * the caches of the collections related to the mutated one, with the edges\n * derived from the manifest (`definition.relationships`) — no hand-wired\n * cache keys. Cross-collection reach requires a shared client from\n * {@link createSmrtWebClient}; with a private client only the mutated\n * collection refetches.\n *\n * Deliberately NOT here yet (see PRD #1755): SvelteKit hydration seeding,\n * offline outbox, SSE invalidation, persistence, version awareness.\n */\n\nimport { createCollection } from '@tanstack/db';\nimport { QueryClient } from '@tanstack/query-core';\nimport { queryCollectionOptions } from '@tanstack/query-db-collection';\n\n// ---------------------------------------------------------------------------\n// Generated definition contract (mirrors @happyvertical/smrt-virt-web)\n// ---------------------------------------------------------------------------\n\n/**\n * Field metadata emitted per column by the `@happyvertical/smrt-virt-web`\n * virtual module (generated from the package manifest).\n */\nexport interface SmrtWebFieldDefinition {\n type: string;\n required?: boolean;\n default?: unknown;\n}\n\n/** The relationship kinds a generated web collection edge can describe. */\nexport type SmrtWebRelationshipKind =\n | 'foreignKey'\n | 'crossPackageRef'\n | 'oneToMany'\n | 'manyToMany';\n\n/**\n * A manifest-derived edge from this collection to a sibling REST collection,\n * emitted by the `@happyvertical/smrt-virt-web` virtual module. When a mutation\n * on this collection settles, the caches of the collections named by these\n * edges are invalidated (relationship-derived invalidation, #1761), so a\n * dependent view refetches without any hand-wired cache key.\n *\n * SMRT-owned data — no client-engine (`@tanstack/*`) type appears here, so it\n * stays inside the engine-absorption boundary.\n */\nexport interface SmrtWebRelationship {\n /** The declaring field carrying the relationship (e.g. `groupId`, `items`). */\n field: string;\n /** The relationship kind, mirroring the manifest field type. */\n kind: SmrtWebRelationshipKind;\n /** REST collection name the edge resolves to (e.g. `ad_groups`). */\n relatedCollection: string;\n}\n\n/**\n * One generated collection definition: everything needed to construct a client\n * collection over the generated REST surface. The `_row` property is a phantom\n * type carrier threaded through codegen — it never exists at runtime, it only\n * lets factories infer the row type from a definition.\n */\nexport interface SmrtWebCollectionDefinition<TData extends object = object> {\n /** REST collection name (e.g. `products`). */\n name: string;\n /** Source class name (e.g. `Product`). */\n className: string;\n /** Path under the API base path (e.g. `/products`). */\n endpoint: string;\n /** Primary key field name (`id` for SmrtObject). */\n idField: string;\n /** CRUD + custom actions exposed by the api decorator config. */\n actions: string[];\n /** Persisted field metadata keyed by field name. */\n fields: Record<string, SmrtWebFieldDefinition>;\n /**\n * Manifest-derived relationship edges to sibling REST collections. Drives\n * relationship-derived cache invalidation: a settled mutation on this\n * collection invalidates the caches of the collections these edges name.\n * Optional so hand-built definitions (older codegen, tests) still satisfy the\n * type; a missing value means \"no derived edges\".\n */\n relationships?: SmrtWebRelationship[];\n /** Phantom row-type carrier — never present at runtime. */\n _row?: TData;\n}\n\n// ---------------------------------------------------------------------------\n// Fetcher contract + payload normalization\n// ---------------------------------------------------------------------------\n\n/**\n * The per-collection CRUD surface of the generated REST client\n * (`createClient(basePath).<collection>` from `@happyvertical/smrt-virt-client`).\n *\n * Return types are `unknown` on purpose: generated fetchers resolve with\n * whatever the server sent, so this package normalizes and validates payloads\n * centrally — see {@link unwrapListResult} / {@link unwrapItemResult}.\n */\nexport interface SmrtCrudFetchers {\n list(params?: Record<string, unknown>): Promise<unknown>;\n get?(id: string): Promise<unknown>;\n create(data: Record<string, unknown>): Promise<unknown>;\n update?(id: string, data: Record<string, unknown>): Promise<unknown>;\n delete?(id: string): Promise<unknown>;\n}\n\n/**\n * Raised when a generated-client call resolved with an error payload\n * (`{ error: string }` from the generated REST routes) or an unexpected shape.\n * Thrown inside a mutation handler, this triggers the automatic rollback of\n * optimistic state.\n */\nexport class SmrtWebRequestError extends Error {\n readonly payload: unknown;\n\n constructor(message: string, payload?: unknown) {\n super(message);\n this.name = 'SmrtWebRequestError';\n this.payload = payload;\n }\n}\n\n/** A row as stored in the client collection: the DTO plus a required key. */\nexport type SmrtWebRow<TData extends object> = TData & { id: string };\n\n/**\n * Normalize a generated-client list result to an array of rows.\n *\n * The generated REST routes return a bare JSON array; `{ error }` payloads are\n * surfaced as failures. The `{ data: [...] }` envelope is tolerated for\n * ApiResponse-shaped clients (e.g. a mock client).\n */\nexport function unwrapListResult(\n result: unknown,\n collectionName: string,\n): Array<Record<string, unknown>> {\n if (Array.isArray(result)) {\n return result as Array<Record<string, unknown>>;\n }\n if (result && typeof result === 'object') {\n const record = result as Record<string, unknown>;\n if (typeof record.error === 'string') {\n throw new SmrtWebRequestError(\n `[smrt-web] list(${collectionName}) failed: ${record.error}`,\n result,\n );\n }\n if (Array.isArray(record.data)) {\n return record.data as Array<Record<string, unknown>>;\n }\n }\n throw new SmrtWebRequestError(\n `[smrt-web] list(${collectionName}) returned an unexpected payload shape`,\n result,\n );\n}\n\n/**\n * Normalize a generated-client item result (create/update) to a row.\n * `{ error }` payloads become failures — inside mutation handlers this is what\n * makes optimistic state roll back.\n */\nexport function unwrapItemResult(\n result: unknown,\n context: string,\n): Record<string, unknown> {\n if (result && typeof result === 'object' && !Array.isArray(result)) {\n const record = result as Record<string, unknown>;\n if (typeof record.error === 'string') {\n throw new SmrtWebRequestError(\n `[smrt-web] ${context} failed: ${record.error}`,\n result,\n );\n }\n if (\n record.data &&\n typeof record.data === 'object' &&\n !Array.isArray(record.data)\n ) {\n return record.data as Record<string, unknown>;\n }\n return record;\n }\n throw new SmrtWebRequestError(\n `[smrt-web] ${context} returned an unexpected payload shape`,\n result,\n );\n}\n\n/**\n * Build CRUD fetchers from a generated collection definition — the same URL\n * scheme and payload handling as the generated REST client\n * (`basePath + endpoint`), with one improvement: HTTP error statuses reject\n * with the server's `{ error }` body instead of resolving with it.\n */\nexport function createDefinitionFetchers(\n definition: SmrtWebCollectionDefinition<object>,\n basePath = '/api/v1',\n fetchFn: typeof fetch = (...args) => globalThis.fetch(...args),\n): SmrtCrudFetchers {\n const collectionUrl = `${basePath}${definition.endpoint}`;\n const headers = { 'Content-Type': 'application/json' };\n\n const parse = async (response: Response): Promise<unknown> => {\n const payload: unknown = await response.json().catch(() => null);\n if (!response.ok) {\n const message =\n payload &&\n typeof payload === 'object' &&\n typeof (payload as Record<string, unknown>).error === 'string'\n ? String((payload as Record<string, unknown>).error)\n : `HTTP ${response.status}`;\n throw new SmrtWebRequestError(\n `[smrt-web] ${definition.name} request failed: ${message}`,\n payload,\n );\n }\n return payload;\n };\n\n return {\n list: async () => parse(await fetchFn(collectionUrl, { headers })),\n get: async (id) =>\n parse(await fetchFn(`${collectionUrl}/${id}`, { headers })),\n create: async (data) =>\n parse(\n await fetchFn(collectionUrl, {\n method: 'POST',\n headers,\n body: JSON.stringify(data),\n }),\n ),\n update: async (id, data) =>\n parse(\n await fetchFn(`${collectionUrl}/${id}`, {\n method: 'PUT',\n headers,\n body: JSON.stringify(data),\n }),\n ),\n delete: async (id) => {\n const response = await fetchFn(`${collectionUrl}/${id}`, {\n method: 'DELETE',\n headers,\n });\n if (!response.ok) {\n throw new SmrtWebRequestError(\n `[smrt-web] delete(${definition.name}) failed: HTTP ${response.status}`,\n );\n }\n return true;\n },\n };\n}\n\n/**\n * Generate a client-local id for optimistic inserts. The generated REST layer\n * strips client-supplied ids on create (mass-assignment guard #1540), so this\n * id only identifies the optimistic row until the post-persist refetch swaps in\n * the server-assigned row.\n */\nexport function newLocalId(): string {\n const cryptoRef = globalThis.crypto as Crypto | undefined;\n if (cryptoRef?.randomUUID) {\n return cryptoRef.randomUUID();\n }\n return `local-${Date.now()}-${Math.random().toString(36).slice(2)}`;\n}\n\n// ---------------------------------------------------------------------------\n// Engine-absorbing public surface (no @tanstack/* types leak past here)\n// ---------------------------------------------------------------------------\n\n/**\n * Opaque handle to the shared client cache / request-dedup layer. Create one\n * with {@link createSmrtWebClient} and pass the SAME instance to every\n * collection that should share a cache and deduplicate in-flight requests.\n *\n * The engine (currently a TanStack Query client) is intentionally hidden behind\n * this brand so it stays swappable — do not depend on its concrete shape.\n */\nexport interface SmrtWebClient {\n /** Phantom brand — this handle wraps the hidden client-cache engine. */\n readonly __smrtWebClient: 'SmrtWebClient';\n}\n\n/**\n * Engine-side shape of a {@link SmrtWebClient}. Never exported, so the engine\n * type never reaches the public surface. Extends the public brand so the value\n * created here carries the brand at runtime (enabling the validation below).\n */\ninterface SmrtWebClientEngine extends SmrtWebClient {\n readonly queryClient: QueryClient;\n}\n\n/**\n * Create a shared client-cache handle. Pass the returned handle as\n * {@link CreateSmrtCollectionOptions.client} to every collection that should\n * share a cache and deduplicate requests app-wide.\n */\nexport function createSmrtWebClient(): SmrtWebClient {\n const engine: SmrtWebClientEngine = {\n __smrtWebClient: 'SmrtWebClient',\n queryClient: new QueryClient(),\n };\n return engine;\n}\n\nfunction resolveQueryClient(client?: SmrtWebClient): QueryClient {\n if (!client) return new QueryClient();\n const engine = client as Partial<SmrtWebClientEngine>;\n if (engine.__smrtWebClient !== 'SmrtWebClient' || !engine.queryClient) {\n throw new SmrtWebRequestError(\n '[smrt-web] options.client must be a handle from createSmrtWebClient()',\n );\n }\n return engine.queryClient;\n}\n\n/**\n * Project an engine row to a plain public DTO. The client-data engine decorates\n * stored rows with enumerable virtual props (`$synced`/`$origin`/`$key`/\n * `$collectionId`) that would otherwise cross the SMRT boundary through spread\n * or JSON serialization. The `$` prefix is reserved for the engine; SMRT\n * columns never begin with it.\n */\nfunction toPlainRow<TData extends object>(row: unknown): SmrtWebRow<TData> {\n const plain: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(row as Record<string, unknown>)) {\n if (key.charCodeAt(0) !== 36 /* '$' */) plain[key] = value;\n }\n return plain as SmrtWebRow<TData>;\n}\n\n/** Project the row values carried by a change notification to plain DTOs. */\nfunction projectChanges(changes: unknown): unknown {\n if (!Array.isArray(changes)) return changes;\n return changes.map((change) => {\n if (!change || typeof change !== 'object') return change;\n const record = change as Record<string, unknown>;\n const projected: Record<string, unknown> = { ...record };\n if (record.value && typeof record.value === 'object') {\n projected.value = toPlainRow(record.value);\n }\n if (record.previousValue && typeof record.previousValue === 'object') {\n projected.previousValue = toPlainRow(record.previousValue);\n }\n return projected;\n });\n}\n\n/**\n * A pending optimistic mutation. Await {@link isPersisted} to observe the\n * server outcome: it resolves once the write has been persisted through the\n * REST surface, and rejects (rolling the optimistic state back) on error.\n */\nexport interface SmrtWebTransaction {\n readonly isPersisted: { readonly promise: Promise<unknown> };\n}\n\n/** A change-subscription handle. Call {@link unsubscribe} to detach. */\nexport interface SmrtWebSubscription {\n unsubscribe(): void;\n}\n\n/**\n * A live, cached collection of plain-DTO rows — the SMRT-owned public contract\n * over the client-data engine. Exposes only the committed surface; the engine's\n * own type is never named here so it stays swappable.\n */\nexport interface SmrtWebCollection<TData extends object> {\n /** All rows currently in the collection (plain DTOs, insertion order). */\n readonly toArray: ReadonlyArray<SmrtWebRow<TData>>;\n /** Number of rows currently in the collection. */\n readonly size: number;\n /** True when a row with `key` is present. */\n has(key: string): boolean;\n /** The row with `key`, or `undefined`. */\n get(key: string): SmrtWebRow<TData> | undefined;\n /** Resolve once the first load has completed. */\n preload(): Promise<void>;\n /** Tear down subscriptions and cached state. */\n cleanup(): Promise<void>;\n /** Subscribe to change notifications; returns a detach handle. */\n subscribeChanges(callback: (changes: unknown) => void): SmrtWebSubscription;\n /**\n * Optimistically insert a row and persist it through the create fetcher. The\n * row is visible synchronously; the returned transaction settles on the\n * server outcome (see {@link SmrtWebTransaction}).\n */\n insert(row: SmrtWebRow<TData>): SmrtWebTransaction;\n}\n\nexport interface CreateSmrtCollectionOptions {\n /**\n * Generated REST client surface for this collection, e.g.\n * `createClient('/api/v1').products` from the virt-client module. When\n * omitted, fetchers are derived from the definition's endpoint and `basePath`\n * with the same URL scheme and payload shapes the generated client uses.\n */\n fetchers?: SmrtCrudFetchers;\n /** API base path for definition-derived fetchers (default `/api/v1`). */\n basePath?: string;\n /** Fetch implementation override (tests, SSR). Defaults to global fetch. */\n fetchFn?: typeof fetch;\n /**\n * Shared cache handle from {@link createSmrtWebClient}. Pass one app-wide\n * instance so collections share a cache and deduplicate requests; a private\n * cache is created when omitted.\n */\n client?: SmrtWebClient;\n /**\n * Cache namespace for this collection's reads. Fold a backend / tenant /\n * preview discriminator in here when the SAME generated collection is\n * materialized against DIFFERENT backends while sharing one {@link client} —\n * without it those reads share a cache key and could serve one backend's rows\n * for the other for the whole `staleTimeMs` window. Omit for the common\n * single-backend case.\n */\n scope?: string;\n /**\n * Stale-while-revalidate window in milliseconds (default 30s): reads within\n * the window are served from the local collection without a network request;\n * the first read after it revalidates in the background.\n */\n staleTimeMs?: number;\n /** Retry failed loads (default false: fail fast, surface errors). */\n retry?: boolean;\n}\n\n/**\n * Registry mapping a public collection handle to its underlying engine\n * collection. Keyed weakly so a handle and its engine collection are collected\n * together. Read only through {@link getEngineCollection}.\n */\nconst engineCollections = new WeakMap<object, unknown>();\n\n/**\n * Retrieve the underlying engine collection backing a handle — an advanced\n * bridge for trusted framework bindings (e.g. the smrt-svelte live-query\n * binding), which must feed the engine collection to the query builder. Returns\n * `unknown` so no engine type crosses the boundary; callers cast. Throws for a\n * handle not produced by {@link createSmrtCollection}. Not needed for normal\n * use.\n */\nexport function getEngineCollection<TData extends object>(\n handle: SmrtWebCollection<TData>,\n): unknown {\n const engine = engineCollections.get(handle);\n if (engine === undefined) {\n throw new SmrtWebRequestError(\n '[smrt-web] getEngineCollection: not a smrt-web collection handle',\n );\n }\n return engine;\n}\n\n/**\n * Create a typed client collection over a generated SMRT collection definition\n * and the matching generated REST client fetchers.\n *\n * Reads: stale-while-revalidate. The first subscriber triggers a fetch;\n * re-subscribing within `staleTimeMs` serves local data with no request. N\n * concurrent identical reads coalesce into one network request.\n *\n * Writes: `collection.insert({ ...data, id: newLocalId() })` applies instantly,\n * persists through `fetchers.create()` (the temp id is stripped — the server\n * assigns the real one), then refetches to reconcile. A failed create rejects\n * the transaction and the optimistic row rolls back automatically.\n *\n * Relationship-derived invalidation: once a create/update/delete has persisted,\n * the query caches of this collection AND the collections named by\n * `definition.relationships` (manifest-derived edges) are invalidated, so\n * dependent views refetch. Reaching OTHER collections requires them to share\n * this collection's `client` (see {@link createSmrtWebClient}); with a private\n * client only this collection refetches.\n */\nexport function createSmrtCollection<TData extends object>(\n definition: SmrtWebCollectionDefinition<TData>,\n options: CreateSmrtCollectionOptions,\n): SmrtWebCollection<TData> {\n type Row = SmrtWebRow<TData>;\n\n const { staleTimeMs = 30_000, retry = false, scope } = options;\n const fetchers =\n options.fetchers ??\n createDefinitionFetchers(definition, options.basePath, options.fetchFn);\n const queryClient = resolveQueryClient(options.client);\n const idField = definition.idField || 'id';\n\n // Scope discriminates the cache key so a shared client can materialize the\n // same collection against different backends without cross-serving reads.\n const cacheId = scope\n ? `smrt:${scope}:${definition.name}`\n : `smrt:${definition.name}`;\n const queryKey = scope\n ? ['smrt', scope, definition.name]\n : ['smrt', definition.name];\n\n // Relationship-derived invalidation target set (#1761): the collections\n // whose caches a settled mutation on THIS collection must invalidate. Always\n // includes this collection itself (so its own read revalidates) plus every\n // manifest-derived related collection. Built once; a settled write matches\n // any cached query whose collection-name segment (the LAST queryKey element,\n // mirroring the `['smrt', (scope,) name]` scheme above) is in this set.\n //\n // Over-invalidation is safe — a stale query merely refetches. Under-\n // invalidation is the bug (a dependent view showing stale rows), so the\n // predicate matches by collection name across ALL scopes rather than an exact\n // key: a mutation in one scope refreshes the related collection in every\n // scope sharing the client.\n const invalidationTargets = new Set<string>([definition.name]);\n for (const relationship of definition.relationships ?? []) {\n invalidationTargets.add(relationship.relatedCollection);\n }\n\n /**\n * Invalidate the query caches of this collection and its manifest-derived\n * related collections. Cross-collection reach requires those collections to\n * share this collection's `client` (from {@link createSmrtWebClient}); with a\n * private client only THIS collection's query lives here, so only it\n * refetches. Fire-and-forget: invalidation schedules a background refetch and\n * must not delay the mutation's own settle.\n */\n const invalidateRelated = (): void => {\n void queryClient.invalidateQueries({\n predicate: (query) => {\n const key = query.queryKey;\n if (!Array.isArray(key) || key.length === 0) return false;\n const collectionSegment = key[key.length - 1];\n return (\n typeof collectionSegment === 'string' &&\n invalidationTargets.has(collectionSegment)\n );\n },\n });\n };\n\n const collection = createCollection(\n queryCollectionOptions<Row>({\n id: cacheId,\n queryKey,\n queryClient,\n staleTime: staleTimeMs,\n retry,\n queryFn: async () =>\n unwrapListResult(await fetchers.list(), definition.name) as Array<Row>,\n getKey: (row) => String((row as Record<string, unknown>)[idField]),\n onInsert: async ({ transaction }) => {\n for (const mutation of transaction.mutations) {\n const modified = mutation.modified as Record<string, unknown>;\n // Strip the client-local id: the generated REST layer rejects or\n // ignores client-supplied ids on create (#1540); the follow-up\n // refetch swaps the optimistic row for the server-assigned one.\n const { [idField]: _localId, ...data } = modified;\n unwrapItemResult(\n await fetchers.create(data),\n `create(${definition.name})`,\n );\n }\n // Persisted: refresh this collection and its related collections. Runs\n // only after every create resolved — a rejected create rolls the\n // optimistic row back and never reaches here.\n invalidateRelated();\n },\n onUpdate: fetchers.update\n ? async ({ transaction }) => {\n for (const mutation of transaction.mutations) {\n const key = String(mutation.key);\n const changes = mutation.changes as Record<string, unknown>;\n unwrapItemResult(\n // biome-ignore lint/style/noNonNullAssertion: guarded by the surrounding ternary\n await fetchers.update!(key, changes),\n `update(${definition.name})`,\n );\n }\n invalidateRelated();\n }\n : undefined,\n onDelete: fetchers.delete\n ? async ({ transaction }) => {\n for (const mutation of transaction.mutations) {\n // biome-ignore lint/style/noNonNullAssertion: guarded by the surrounding ternary\n await fetchers.delete!(String(mutation.key));\n }\n invalidateRelated();\n }\n : undefined,\n }),\n );\n\n // Wrap the engine collection in the SMRT-owned public surface. The wrapper\n // projects rows to plain DTOs at every read boundary (toArray/get and change\n // payloads) so the engine's virtual props never escape, and confines the\n // engine's own types to this module.\n const handle: SmrtWebCollection<TData> = {\n get toArray() {\n return collection.toArray.map((row) => toPlainRow<TData>(row));\n },\n get size() {\n return collection.size;\n },\n has(key) {\n return collection.has(key);\n },\n get(key) {\n const row = collection.get(key);\n return row === undefined ? undefined : toPlainRow<TData>(row);\n },\n preload() {\n return collection.preload();\n },\n cleanup() {\n return collection.cleanup();\n },\n subscribeChanges(callback) {\n const subscription = collection.subscribeChanges((changes: unknown) =>\n callback(projectChanges(changes)),\n );\n return { unsubscribe: () => subscription.unsubscribe() };\n },\n insert(row) {\n return collection.insert(row) as unknown as SmrtWebTransaction;\n },\n };\n\n engineCollections.set(handle, collection);\n return handle;\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@happyvertical/smrt-web",
|
|
3
|
+
"version": "0.37.10",
|
|
4
|
+
"description": "SMRT browser client data runtime: typed collection factory wrapping the client-data engine over generated REST clients",
|
|
5
|
+
"author": "HappyVertical",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"CLAUDE.md",
|
|
11
|
+
"dist",
|
|
12
|
+
"AGENTS.md"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@tanstack/db": "^0.6.14",
|
|
22
|
+
"@tanstack/query-core": "^5.90.5",
|
|
23
|
+
"@tanstack/query-db-collection": "^1.0.46"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "24.13.2",
|
|
27
|
+
"typescript": "^5.9.3",
|
|
28
|
+
"vite": "8.1.2",
|
|
29
|
+
"vitest": "^4.1.9"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"registry": "https://registry.npmjs.org",
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/happyvertical/smrt.git",
|
|
38
|
+
"directory": "packages/smrt-web"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "vite build --mode library && node ../../scripts/check-smrt-web-engine-boundary.mjs",
|
|
42
|
+
"build:watch": "vite build --mode library --watch",
|
|
43
|
+
"dev": "vite build --mode library --watch",
|
|
44
|
+
"clean": "rm -rf dist",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:watch": "vitest",
|
|
47
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
48
|
+
"verify:pack": "node ../../scripts/verify-package-types-exports.js ."
|
|
49
|
+
}
|
|
50
|
+
}
|