@weft-js/start 0.0.0-stage → 0.1.1
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/LICENSE +16 -0
- package/README.md +41 -2
- package/package.json +107 -4
- package/src/app.mjs +93 -0
- package/src/auth/better-auth.ts +58 -0
- package/src/auth/drizzle.ts +17 -0
- package/src/auth.ts +47 -0
- package/src/boot.ts +44 -0
- package/src/builtin.tsx +19 -0
- package/src/bus/postgres.ts +91 -0
- package/src/bus.ts +48 -0
- package/src/capture.d.mts +1 -0
- package/src/capture.mjs +15 -0
- package/src/changes-sql.d.mts +14 -0
- package/src/changes-sql.mjs +99 -0
- package/src/changes.ts +253 -0
- package/src/channel-fake.ts +55 -0
- package/src/channel.ts +622 -0
- package/src/client.tsx +20 -0
- package/src/contract.mjs +312 -0
- package/src/data.ts +929 -0
- package/src/db/bun-sqlite.d.ts +10 -0
- package/src/db/changes-mysql.ts +86 -0
- package/src/db/changes-postgres.ts +159 -0
- package/src/db/changes-sqlite.ts +116 -0
- package/src/db/core.ts +63 -0
- package/src/db/index.ts +27 -0
- package/src/db/mysql.ts +67 -0
- package/src/db/postgres.ts +65 -0
- package/src/db/sqlite.ts +139 -0
- package/src/derive.d.mts +16 -0
- package/src/derive.mjs +140 -0
- package/src/dev-runtime.mjs +42 -0
- package/src/exec.ts +519 -0
- package/src/extension.ts +38 -0
- package/src/first-paint.ts +57 -0
- package/src/form.tsx +134 -0
- package/src/gate.css +6 -0
- package/src/hub.ts +822 -0
- package/src/index.ts +106 -0
- package/src/inline.mjs +2 -0
- package/src/inspect.mjs +280 -0
- package/src/link.ts +461 -0
- package/src/list-drizzle.ts +50 -0
- package/src/list.ts +224 -0
- package/src/listen.ts +28 -0
- package/src/live-fake.ts +42 -0
- package/src/live-target.mjs +147 -0
- package/src/live.ts +499 -0
- package/src/media.ts +43 -0
- package/src/migrations.mjs +19 -0
- package/src/mux-session.ts +6 -0
- package/src/mux.ts +135 -0
- package/src/og/DefaultCard.tsx +60 -0
- package/src/og/fonts.mjs +64 -0
- package/src/og/images.mjs +54 -0
- package/src/og/index.mjs +146 -0
- package/src/og/native.mjs +29 -0
- package/src/range.mjs +19 -0
- package/src/router.ts +149 -0
- package/src/routes.mjs +142 -0
- package/src/run.tsx +202 -0
- package/src/rust.ts +333 -0
- package/src/server.tsx +1021 -0
- package/src/shape.d.mts +3 -0
- package/src/shape.mjs +73 -0
- package/src/share-server.ts +208 -0
- package/src/share.ts +503 -0
- package/src/sheets.mjs +75 -0
- package/src/sidecar.mjs +503 -0
- package/src/socket.ts +389 -0
- package/src/stream.ts +13 -0
- package/src/verify.ts +251 -0
- package/src/view.tsx +47 -0
- package/src/vite.mjs +899 -0
package/src/data.ts
ADDED
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
// Server functions and the client data cache (research 27). `query`/`mutation` definitions live in server modules
|
|
2
|
+
// (`src/server/**`, `*.server.ts`); the `weft:start` plugin gives each an id and, for the client, strips everything
|
|
3
|
+
// but `optimistic`. One definition is then:
|
|
4
|
+
//
|
|
5
|
+
// - on the server: `q.fetch(input)` runs `run` through the request cache (exec.ts), at most once per request;
|
|
6
|
+
// - in a component or scope: `q(input)` is a `Query` (a Resource) reading the client cache. Its fetcher reads the
|
|
7
|
+
// entry's value, so a new server value or an optimistic layer updates every reader in place;
|
|
8
|
+
// - a mutation `m(input)` POSTs, applies `optimistic` layers at once, and settles with the refreshed queries the
|
|
9
|
+
// server sends back in the same response (single-flight).
|
|
10
|
+
//
|
|
11
|
+
// The cache entry's value is `base` with each pending layer applied. New bases are structurally shared with the old
|
|
12
|
+
// one, so unchanged rows keep their identity and `For` keeps them. This module ships to the client; the executor
|
|
13
|
+
// (exec.ts) is the server's, and the in-process fake transport @weft-js/core/test turns on loads it on demand. Realtime
|
|
14
|
+
// (research 30) costs a query app one method here: `q.live()` imports live.ts on its first call.
|
|
15
|
+
|
|
16
|
+
import { DEV, createRoot, derived, effect, getOwner, onCleanup, resource, state, untrack, type Derived, type Resource, type State } from '@weft-js/core'
|
|
17
|
+
import { fill, mask } from '@weft-js/core/shape'
|
|
18
|
+
import { client } from './router'
|
|
19
|
+
import type { MediaAsset } from '@weft-js/core/media'
|
|
20
|
+
import type * as Exec from './exec'
|
|
21
|
+
import type { Scope } from './exec'
|
|
22
|
+
import type { ServerExtension } from './extension'
|
|
23
|
+
|
|
24
|
+
// ---- definitions ------------------------------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Here and not in verify.ts: that file needs `postgres`, and an app without it still reads this type. */
|
|
27
|
+
export interface VerifyOptions {
|
|
28
|
+
/** `true` or a target's name, as `verify: true` and `verify: 'staging'` mean, for when the rest of this is needed too. */
|
|
29
|
+
target?: true | string
|
|
30
|
+
/** A running staging app. Fine in code. */
|
|
31
|
+
url?: string
|
|
32
|
+
/** A database, from the environment: `process.env.STAGING_DATABASE_URL`. */
|
|
33
|
+
db?: string
|
|
34
|
+
/** The query's input. */
|
|
35
|
+
input?: unknown
|
|
36
|
+
/** Columns of the test row: whose it is, so the signed-in test user can see it. The rest are made up. */
|
|
37
|
+
row?: Record<string, unknown>
|
|
38
|
+
/** Name of the env var with a cookie for a user who can see the row. Default `WEFT_STAGING_COOKIE`. */
|
|
39
|
+
cookie?: string
|
|
40
|
+
/** Name of the env var with a cookie for a user who can't. Default `WEFT_STAGING_OTHER_COOKIE`; without it, a signed-out client. */
|
|
41
|
+
otherCookie?: string
|
|
42
|
+
/** Ms each link may take. Default 5000. */
|
|
43
|
+
timeout?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The Standard Schema interface (standardschema.dev): Zod, Valibot, ArkType and Drizzle's derived schemas. */
|
|
47
|
+
export interface StandardSchemaV1<I = unknown, O = I> {
|
|
48
|
+
readonly '~standard': {
|
|
49
|
+
readonly version: 1
|
|
50
|
+
readonly vendor: string
|
|
51
|
+
readonly validate: (value: unknown) => SchemaResult<O> | Promise<SchemaResult<O>>
|
|
52
|
+
readonly types?: { readonly input: I; readonly output: O } | undefined
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** What a Standard Schema validator answers. */
|
|
56
|
+
export type SchemaResult<O> = { readonly value: O; readonly issues?: undefined } | { readonly issues: readonly SchemaIssue[] }
|
|
57
|
+
/** One failure from a schema, with the path to the field when there is one. */
|
|
58
|
+
export interface SchemaIssue { readonly message: string; readonly path?: readonly (PropertyKey | { readonly key: PropertyKey })[] | undefined }
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What `run` and `load` get. Augment it with what `src/context.server.ts` returns:
|
|
62
|
+
* `declare module '@weft-js/start' { interface Context { db: Db; user: User | null } }`.
|
|
63
|
+
*/
|
|
64
|
+
export interface Context {
|
|
65
|
+
request: Request
|
|
66
|
+
/** Headers merged into the response (cookies a sign-in sets). */
|
|
67
|
+
response: { headers: Headers }
|
|
68
|
+
/** Uploads and CMS images (research 29 §6): `ingest` stores the original and returns the record `z.media()` validates. */
|
|
69
|
+
media: { ingest(file: Blob | Uint8Array, options?: { name?: string }): Promise<MediaAsset> }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The address the server saw (never one a client sent): Better Auth's rate limits key on it. */
|
|
73
|
+
export const CLIENT_IP = 'x-weft-client-ip'
|
|
74
|
+
|
|
75
|
+
/** Options a plugin adds to `query` and `mutation`, by augmenting this; its extension's `guard` reads them. Empty here. */
|
|
76
|
+
export interface FnOptions {}
|
|
77
|
+
|
|
78
|
+
/** `'minutes'` etc. or seconds: how long the shared server cache keeps a value (research 27 §4). */
|
|
79
|
+
export type Life = 'seconds' | 'minutes' | 'hours' | 'days' | 'max' | number
|
|
80
|
+
|
|
81
|
+
/** `auth: 'user'` narrows `ctx.user` to non-null in `run` (A002 answers a call with no session). */
|
|
82
|
+
export type AuthScope = 'user'
|
|
83
|
+
type Ctx<A> = A extends 'user' ? (Context extends { user: infer U } ? Omit<Context, 'user'> & { user: NonNullable<U> } : Context) : Context
|
|
84
|
+
|
|
85
|
+
type In<S> = S extends StandardSchemaV1<infer I, unknown> ? I : void
|
|
86
|
+
type Out<S> = S extends StandardSchemaV1<unknown, infer O> ? O : undefined
|
|
87
|
+
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T
|
|
88
|
+
type Tags<A extends unknown[]> = readonly string[] | ((...args: A) => readonly string[])
|
|
89
|
+
/** Read before `run`, for one user's tag: `tags: (_, ctx) => [`entitlements:${ctx.user.id}`]`. */
|
|
90
|
+
type QueryTags<I, A> = Tags<[input: I, ctx: Ctx<A>]>
|
|
91
|
+
|
|
92
|
+
/** What an `Impl` is called with. */
|
|
93
|
+
export interface ImplCall { fn: Fn; input: unknown; ctx: Context; request: Request }
|
|
94
|
+
|
|
95
|
+
/** A server function's body in Rust: `remote('rust://search')` or `native('media')`, from `@weft-js/start/rust` (research 36, 37). */
|
|
96
|
+
export interface Impl { readonly kind: string; readonly target: string; readonly service: string; call(c: ImplCall): Promise<{ value: unknown; tags: string[]; touched: string[] }> }
|
|
97
|
+
|
|
98
|
+
/** Errors a function raises by name, with the data each carries: a Rust function's become an enum. */
|
|
99
|
+
export type Errors = Record<string, StandardSchemaV1>
|
|
100
|
+
|
|
101
|
+
/** `query()` with a `run` on this server. */
|
|
102
|
+
export interface QueryDef<S, O, A = undefined> extends FnOptions {
|
|
103
|
+
input?: S
|
|
104
|
+
/** The result's shape: checked in dev and tests (C002), and the contract's `output`. */
|
|
105
|
+
output?: StandardSchemaV1
|
|
106
|
+
/** `'user'`: refuse a call with no session (A002). The contract's auth scope. */
|
|
107
|
+
auth?: A
|
|
108
|
+
/** An example result, for what `output` can't say: sizes a pending view (research 31 §2). */
|
|
109
|
+
shape?: O extends (infer R)[] ? DeepPartial<R>[] : DeepPartial<O>
|
|
110
|
+
/** What invalidates it: the client cache, the request cache and the shared cache all go by these. */
|
|
111
|
+
tags?: QueryTags<Out<S>, A>
|
|
112
|
+
/** `required: true`: `weft check` and `weft build` fail on a target with no real change feed, and the server won't start without one (R013, R015). */
|
|
113
|
+
live?: {
|
|
114
|
+
required?: boolean
|
|
115
|
+
/** A real round trip against a target that isn't production, in `weft check` and `weft build`. `true` finds `WEFT_STAGING_URL` or `STAGING_DATABASE_URL`; a string names a target in `live.targets`. */
|
|
116
|
+
verify?: true | string | VerifyOptions
|
|
117
|
+
}
|
|
118
|
+
/** Turn on the shared server cache (across requests). Omit it and the query runs once per request. */
|
|
119
|
+
cache?: { life?: Life; /** One cached value per signed-in user: `ctx.user` is readable. */ scope?: 'user' }
|
|
120
|
+
/** Named failures, for the client as `FnError.code` and `.data`. */
|
|
121
|
+
errors?: Errors
|
|
122
|
+
run: (args: { input: Out<S>; ctx: Ctx<A> }) => O | Promise<O>
|
|
123
|
+
impl?: undefined
|
|
124
|
+
/** Set by the `weft:start` plugin: `<export>.<file hash>`. */
|
|
125
|
+
$id?: string
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The same declaration with its body elsewhere: `output` is required, because it's the whole contract for what comes back. */
|
|
129
|
+
export type ImplQueryDef<S, OS extends StandardSchemaV1, A = undefined> = Omit<QueryDef<S, Out<OS>, A>, 'run' | 'impl' | 'output'> & { output: OS; impl: Impl; run?: undefined }
|
|
130
|
+
/** `mutation()` with `impl` and an `output` schema: no `run` here. */
|
|
131
|
+
export type ImplMutationDef<S, OS extends StandardSchemaV1, A = undefined> = Omit<MutationDef<S, Out<OS>, A>, 'run' | 'impl' | 'output'> & { output: OS; impl: Impl; run?: undefined }
|
|
132
|
+
|
|
133
|
+
/** `mutation()` with a `run` on this server. */
|
|
134
|
+
export interface MutationDef<S, O, A = undefined> extends FnOptions {
|
|
135
|
+
input?: S
|
|
136
|
+
output?: StandardSchemaV1
|
|
137
|
+
auth?: A
|
|
138
|
+
/** Tags whose queries the response refreshes (single-flight) and whose cached values it drops. */
|
|
139
|
+
invalidates?: Tags<[input: Out<S>, result: Awaited<O>]>
|
|
140
|
+
/** Client only: predict the result. Each call's layers are re-applied on new values until it settles. */
|
|
141
|
+
optimistic?: (args: { input: In<S> }, cache: OptimisticCache) => void
|
|
142
|
+
errors?: Errors
|
|
143
|
+
run: (args: { input: Out<S>; ctx: Ctx<A> }) => O | Promise<O>
|
|
144
|
+
impl?: undefined
|
|
145
|
+
$id?: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The typed "no query" input: `getTodo(() => (id.value ? { id: id.value } : skip))`. */
|
|
149
|
+
export const skip: unique symbol = Symbol.for('weft.skip') as never
|
|
150
|
+
/** The marker a live query's input takes to subscribe to nothing yet. */
|
|
151
|
+
export type Skip = typeof skip
|
|
152
|
+
|
|
153
|
+
type Arg<I> = I | (() => I | Skip)
|
|
154
|
+
|
|
155
|
+
/** What `query()` returns: call it for a resource, or `.live()` for one that stays current. */
|
|
156
|
+
export interface Query<I, O> {
|
|
157
|
+
/** The live value, read in a component or a defineState setup (Q001 elsewhere: use `fetch`). */
|
|
158
|
+
(...args: [I] extends [void] ? [input?: undefined | (() => undefined | Skip)] : [input: Arg<I>]): Resource<O>
|
|
159
|
+
/** The value as a promise: in `load`, handlers, server code and tests. Through the request or client cache. */
|
|
160
|
+
fetch(...args: [I] extends [void] ? [] : [input: I]): Promise<O>
|
|
161
|
+
/** A placeholder result to size a pending view by (last seen, else declared), or undefined when nothing is known. */
|
|
162
|
+
shape(...args: [I] extends [void] ? [] : [input: I]): O | undefined
|
|
163
|
+
/**
|
|
164
|
+
* The same value, refetched whenever a mutation anywhere invalidates one of its tags. `.live` is the connection's
|
|
165
|
+
* status. `keep`: how long the subscription outlives its last reader (`'30s'`, `'session'`).
|
|
166
|
+
*/
|
|
167
|
+
live(...args: [I] extends [void] ? [input?: undefined | (() => undefined | Skip), options?: LiveOptions] : [input: Arg<I>, options?: LiveOptions]): LiveQuery<O>
|
|
168
|
+
readonly kind: 'query'
|
|
169
|
+
readonly $id: string
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** What `mutation()` returns: call it, or read its `pending` and `error`. */
|
|
173
|
+
export interface Mutation<I, O> {
|
|
174
|
+
(...args: [I] extends [void] ? [] : [input: I]): Promise<O>
|
|
175
|
+
/** A call is in flight. */
|
|
176
|
+
readonly pending: Derived<boolean>
|
|
177
|
+
/** The last call's failure, until the next call succeeds. */
|
|
178
|
+
readonly error: Derived<unknown>
|
|
179
|
+
readonly kind: 'mutation'
|
|
180
|
+
readonly $id: string
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `degraded`: connected, and writes from outside the app arrive late or not at all. `.liveMode` says which. */
|
|
184
|
+
export type LiveStatus = 'connecting' | 'live' | 'degraded' | 'reconnecting' | 'offline'
|
|
185
|
+
/**
|
|
186
|
+
* How writes reach this page. `app`: mutations only, no `changes` configured. `feed`: the database reports every change.
|
|
187
|
+
* `external`: a separate listener does, through the bus. `poll`: an estimate, every few seconds. `off`: mutations only, though `changes` asks for more.
|
|
188
|
+
*/
|
|
189
|
+
export type LiveMode = 'app' | 'feed' | 'external' | 'poll' | 'off'
|
|
190
|
+
/** `.live()`: how long it's kept, and whether it refuses to run without a real feed. */
|
|
191
|
+
export interface LiveOptions {
|
|
192
|
+
keep?: string | number
|
|
193
|
+
/** Fail `weft check`, `weft build` and server startup rather than run without a real change feed (R013, R015). */
|
|
194
|
+
required?: boolean
|
|
195
|
+
}
|
|
196
|
+
/** A resource that also says how live it is. */
|
|
197
|
+
export type LiveQuery<O> = Resource<O> & { readonly live: Derived<LiveStatus>; readonly liveMode: Derived<LiveMode> }
|
|
198
|
+
|
|
199
|
+
/** Any definition, as the executor and transports see it. */
|
|
200
|
+
export interface Fn {
|
|
201
|
+
kind: 'query' | 'mutation' | 'stream' | 'channel' | 'shared'
|
|
202
|
+
$id: string
|
|
203
|
+
def: {
|
|
204
|
+
input?: StandardSchemaV1; output?: StandardSchemaV1; message?: StandardSchemaV1; shape?: unknown; auth?: AuthScope; run?: (args: { input: unknown; ctx: Context; signal?: AbortSignal; lastEventId?: unknown }) => unknown; impl?: Impl; errors?: Errors; tags?: Tags<[input: unknown, ctx?: Context]>; cache?: { life?: Life; scope?: 'user' }; invalidates?: Tags<[unknown, unknown]>; optimistic?: (args: { input: unknown }, cache: OptimisticCache) => void
|
|
205
|
+
/** `channel` and `sharedState` (research 32). */
|
|
206
|
+
params?: StandardSchemaV1; authorize?: unknown; left?: (args: { id: string; ctx: Context; params: unknown; room: unknown }) => unknown; recheck?: (args: { id: string; ctx: Context; reply: unknown }) => unknown; client?: Record<string, StandardSchemaV1>; server?: Record<string, StandardSchemaV1>; on?: Record<string, (args: never) => unknown>; limits?: { perSecond?: number }; key?: StandardSchemaV1; value?: StandardSchemaV1; initial?: unknown
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Every definition by id (the server answers `/_weft/fn/<id>` from it). */
|
|
211
|
+
export const fns = new Map<string, Fn>()
|
|
212
|
+
let auto = 0
|
|
213
|
+
|
|
214
|
+
// ---- errors and redirects -----------------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
/** A diagnostic thrown by @weft-js/start: `code` (Q001…) and a fix in the message, like weft's StateError. */
|
|
217
|
+
export class StartError extends Error {
|
|
218
|
+
/** The HTTP status, when `extra` sets one. */
|
|
219
|
+
declare status?: number
|
|
220
|
+
/** Field by field, when `extra` sets them. */
|
|
221
|
+
declare issues?: SchemaIssue[]
|
|
222
|
+
/** What a declared error carries. */
|
|
223
|
+
declare data?: unknown
|
|
224
|
+
/** `raise`: this is a failure the function declares, so its message reaches the client. */
|
|
225
|
+
declare typed?: boolean
|
|
226
|
+
constructor(public code: string, message: string, fix?: string, public extra: Record<string, unknown> = {}) {
|
|
227
|
+
super(`${code}: ${message}${fix ? `\n fix: ${fix}` : ''}`)
|
|
228
|
+
Object.assign(this, extra)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A failed server function call, as the client sees it. */
|
|
233
|
+
export class FnError extends Error {
|
|
234
|
+
code?: string
|
|
235
|
+
status: number
|
|
236
|
+
issues?: SchemaIssue[]
|
|
237
|
+
/** What a declared error (`errors`) carries. */
|
|
238
|
+
data?: unknown
|
|
239
|
+
constructor(e: { message?: string; code?: string; issues?: SchemaIssue[] | undefined; data?: unknown }, status: number) {
|
|
240
|
+
super(e.message ?? `server function failed (${status})`)
|
|
241
|
+
this.status = status
|
|
242
|
+
Object.assign(this, { code: e.code, issues: e.issues, data: e.data })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
declare const redirectBrand: unique symbol
|
|
247
|
+
/** What `redirect()` throws: a loader or a function's way to send the page elsewhere. */
|
|
248
|
+
export interface Redirect {
|
|
249
|
+
readonly isRedirect: true
|
|
250
|
+
readonly href: string
|
|
251
|
+
readonly status: number
|
|
252
|
+
readonly [redirectBrand]?: true
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Send the request elsewhere: in `load` (a page answers 307 with `location`, client navigation follows) or in a
|
|
257
|
+
* `run` (the client router navigates). It throws, so `(await db.get(id)) ?? redirect('/todos')` types as the row.
|
|
258
|
+
*/
|
|
259
|
+
export function redirect(href: string, status: 301 | 302 | 303 | 307 | 308 = 307): never {
|
|
260
|
+
// oxlint-disable-next-line typescript/only-throw-error -- control flow, not a failure: a stack costs and nothing reads it
|
|
261
|
+
throw { isRedirect: true, href, status } as Redirect
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Whether a thrown value is a `redirect()`. */
|
|
265
|
+
export const isRedirect = (v: unknown): v is Redirect => !!v && typeof v === 'object' && (v as Partial<Redirect>).isRedirect === true
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Fail with an error the function declares in `errors`. The client sees it as `FnError.code` and `.data`, whether `run`
|
|
269
|
+
* or a Rust `impl` raised it.
|
|
270
|
+
*/
|
|
271
|
+
export function raise(code: string, data?: unknown): never {
|
|
272
|
+
throw new StartError(code, `failed: ${code}`, undefined, { status: 400, data, typed: true })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Refuse a value the schema can't judge (`email` is taken): `<Form>` shows it next to the field, as a schema issue. */
|
|
276
|
+
export function invalid(fields: Record<string, string>): never {
|
|
277
|
+
throw new StartError('Q002', Object.keys(fields).join(', '), undefined, { status: 422, issues: Object.entries(fields).map(([k, message]) => ({ message, path: k.split('.') })) })
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Issues as a form shows them: by field (`a.b`), and the ones that name no field. */
|
|
281
|
+
export function fieldsOf(issues: readonly SchemaIssue[] = []) {
|
|
282
|
+
const errors: Record<string, string> = {}
|
|
283
|
+
let message: string | undefined
|
|
284
|
+
for (const i of issues) {
|
|
285
|
+
const key = i.path?.map(p => (typeof p === 'object' ? p.key : p)).join('.')
|
|
286
|
+
if (key) errors[key] ??= i.message
|
|
287
|
+
else message ??= i.message
|
|
288
|
+
}
|
|
289
|
+
return { errors, message }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
type Tree = Record<string, unknown>
|
|
293
|
+
|
|
294
|
+
const UNSAFE = new Set(['__proto__', 'constructor', 'prototype'])
|
|
295
|
+
|
|
296
|
+
/** A form's fields as a mutation's input, on both sides (research 29 §5): `a.b` nests, `x[]` and repeats are arrays. */
|
|
297
|
+
export function decodeForm(data: FormData): Record<string, unknown> {
|
|
298
|
+
const out: Tree = {}
|
|
299
|
+
for (const [name, value] of data) {
|
|
300
|
+
if (name[0] === '$' || (typeof value !== 'string' && !value.size)) continue // `$fn`; a file input left empty
|
|
301
|
+
const list = name.endsWith('[]')
|
|
302
|
+
const path = (list ? name.slice(0, -2) : name).split('.')
|
|
303
|
+
if (path.some(p => UNSAFE.has(p))) continue
|
|
304
|
+
let at = out
|
|
305
|
+
for (const p of path.slice(0, -1)) {
|
|
306
|
+
const next = at[p]
|
|
307
|
+
at = (next && typeof next === 'object' ? next : (at[p] = {})) as Tree
|
|
308
|
+
}
|
|
309
|
+
const k = path.at(-1)!
|
|
310
|
+
// Own keys only: a field named `toString` is a field, not the method every object inherits.
|
|
311
|
+
const had = Object.hasOwn(at, k)
|
|
312
|
+
if (list) ((had ? at[k] : (at[k] = [])) as unknown[]).push(value)
|
|
313
|
+
else if (had) at[k] = [at[k]].flat().concat(value)
|
|
314
|
+
else at[k] = value
|
|
315
|
+
}
|
|
316
|
+
return out
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---- keys -------------------------------------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
/** JSON with sorted object keys: the same input gives the same key on both sides (no input: `''`; JSON sends null). */
|
|
322
|
+
export function stable(v: unknown): string {
|
|
323
|
+
if (v == null) return ''
|
|
324
|
+
return JSON.stringify(v, (_, x: Record<string, unknown>) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map(k => [k, x[k]])) : x))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The cache key of one call: the function's id and its input in a stable order. */
|
|
328
|
+
export const keyOf = (id: string, input: unknown) => `${id}:${stable(input)}`
|
|
329
|
+
|
|
330
|
+
// ---- runtime hooks ------------------------------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
/** One cached entry as the server sends it: `[id, input, value, tags]` (page payload, navigation, refreshes). */
|
|
333
|
+
export type Wire = [id: string, input: unknown, value: unknown, tags: string[]]
|
|
334
|
+
|
|
335
|
+
/** What the transports answer (research 27 §3). */
|
|
336
|
+
export interface Answer { v?: unknown; t?: string[]; c?: Wire[]; r?: string; /** `r` is a full load: the session changed, so nothing cached may survive. */ x?: 1 }
|
|
337
|
+
|
|
338
|
+
/** How a client call reaches the server: `fetch` by default, a fake under test. */
|
|
339
|
+
export interface Transport {
|
|
340
|
+
query(id: string, input: unknown): Promise<Answer>
|
|
341
|
+
/** `form`: the input came from a form, so strings may be coerced; a FormData is sent as is (it holds files). */
|
|
342
|
+
mutate(id: string, input: unknown, refresh: [string, unknown][], form?: true | FormData): Promise<Answer>
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Set by the server half (server.tsx): the request in progress and the executor. `navigate` overrides the client
|
|
347
|
+
* router's (tests). Unset in plain tests.
|
|
348
|
+
*/
|
|
349
|
+
export const runtime: {
|
|
350
|
+
scope?: (() => Scope | undefined) | undefined
|
|
351
|
+
/** Run `fn` with `scope` current (the request's AsyncLocalStorage). */
|
|
352
|
+
enter?: (<T>(scope: unknown, fn: () => T) => T) | undefined
|
|
353
|
+
exec?: typeof Exec
|
|
354
|
+
transport?: Transport
|
|
355
|
+
navigate?: ((href: string, replace?: boolean) => void) | undefined
|
|
356
|
+
/** Set by the server's first request (and in dev when `drizzle/` grows): what each database applies before its first statement (db/core.ts). */
|
|
357
|
+
migrations?: { list: { tag: string; sql: string }[]; report(e: unknown): void } | undefined
|
|
358
|
+
/** The gates of clients whose statements can't be held (Prisma's): the server waits on them per request, and a failure doesn't fail it. */
|
|
359
|
+
dbs?: Set<() => Promise<void> | undefined>
|
|
360
|
+
/** Fan invalidated tags out to live subscribers (hub.ts). `origin`: the connection that needs no poke. */
|
|
361
|
+
poke?: ((tags: string[], origin?: string | null) => Promise<void>) | undefined
|
|
362
|
+
/** This tab's realtime connection: sent with mutations, so the tab doesn't poke itself. */
|
|
363
|
+
conn?: string | undefined
|
|
364
|
+
/** `room.in(params).publish`: socket.ts, on a server or under the fake. */
|
|
365
|
+
channels?: { publish(fn: Fn, params: unknown, name: string, message: unknown, options?: { exceptSelf?: boolean; from?: string }): Promise<void>; set(fn: Fn, key: unknown, value: unknown): Promise<void> }
|
|
366
|
+
} = {}
|
|
367
|
+
|
|
368
|
+
/** The realtime connection's status and "this tab is older than the server" (research 30 §2). */
|
|
369
|
+
export const rt = createRoot(() => ({ status: state<LiveStatus>('connecting'), mode: state<LiveMode>('app'), stale: state(false) }), null)
|
|
370
|
+
/** The server runs a different build than this tab loaded: offer a reload. */
|
|
371
|
+
export const stale: Derived<boolean> = rt.stale
|
|
372
|
+
|
|
373
|
+
const go = (href: string, replace?: boolean) => (runtime.navigate ? runtime.navigate(href, replace) : void client.navigate?.(href, { replace }))
|
|
374
|
+
|
|
375
|
+
/** @weft-js/core/test's controls, when it's loaded; the first look registers the cache reset it runs after each test. */
|
|
376
|
+
let registered = false
|
|
377
|
+
/** The test controls, once `@weft-js/core/test` has registered them. */
|
|
378
|
+
export const net = () => {
|
|
379
|
+
const n = Reflect.get(globalThis, Symbol.for('weft.net')) as NetControl | undefined
|
|
380
|
+
if (n && !registered) {
|
|
381
|
+
registered = true
|
|
382
|
+
n.resets.push(() => {
|
|
383
|
+
cache.clear()
|
|
384
|
+
callId = 0
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
return n
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** @weft-js/core/test's fake-transport controls (research 27 §7). */
|
|
391
|
+
export interface NetControl {
|
|
392
|
+
latency: number
|
|
393
|
+
ctx?: object
|
|
394
|
+
/** Server extensions for this test (`net.extension`). */
|
|
395
|
+
extensions: ServerExtension[]
|
|
396
|
+
online(): boolean
|
|
397
|
+
fails: Map<string, unknown[]>
|
|
398
|
+
handlers: Map<string, (input: unknown, ctx: Context) => unknown>
|
|
399
|
+
log: { id: string; kind: string; input: unknown }[]
|
|
400
|
+
inflight: Set<Promise<unknown>>
|
|
401
|
+
als: { run<T>(store: unknown, fn: () => T): T; getStore(): unknown }
|
|
402
|
+
resets: (() => unknown)[]
|
|
403
|
+
/** Subscriptions an owner holds: open at the end of a test, they're T012. */
|
|
404
|
+
open: Map<object, [what: string, close: () => void]>
|
|
405
|
+
/** The realtime fake, while one is connected: `net.push`, `net.poke`, `net.drop`. */
|
|
406
|
+
rt?: { push(id: string, msg: unknown): number; poke(tags: string[]): Promise<void>; drop(): void; subscriptions(): { kind: string; id: string; input: unknown }[] } | undefined
|
|
407
|
+
/** The channel fake, while a socket is open. */
|
|
408
|
+
ws?: { publish(id: string, params: unknown, name: string, msg: unknown): Promise<void>; drop(): void; recheck(): Promise<void> } | undefined
|
|
409
|
+
/** What this client's sockets sent, across reconnects. */
|
|
410
|
+
sent: { id: string; name: string; msg: unknown }[]
|
|
411
|
+
reconnects: number
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---- definitions --------------------------------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
const oneBody = (def: { run?: unknown; impl?: unknown; $id?: string }) => {
|
|
417
|
+
if (def.run && def.impl) throw new StartError('R103', `\`${def.$id ?? 'a function'}\` has both \`run\` and \`impl\``, 'keep one: `impl` replaces the body')
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** A server function that reads, with its output typed by a schema. */
|
|
421
|
+
export function query<OS extends StandardSchemaV1, S extends StandardSchemaV1 | undefined = undefined, const A extends AuthScope | undefined = undefined>(def: ImplQueryDef<S, OS, A>): Query<In<S>, Out<OS>>
|
|
422
|
+
/** A server function that reads, with its output typed by `run`. */
|
|
423
|
+
export function query<O, S extends StandardSchemaV1 | undefined = undefined, const A extends AuthScope | undefined = undefined>(def: QueryDef<S, O, A>): Query<In<S>, Awaited<O>>
|
|
424
|
+
/** A server function that reads: cached by input on the client, and tagged for what invalidates it. */
|
|
425
|
+
export function query(def: unknown): unknown {
|
|
426
|
+
const d = def as Fn['def'] & { $id?: string }
|
|
427
|
+
oneBody(d)
|
|
428
|
+
const id = d.$id ?? `q${++auto}`
|
|
429
|
+
const fn: Fn = { kind: 'query', $id: id, def: d }
|
|
430
|
+
fns.set(id, fn)
|
|
431
|
+
const q = (input?: unknown) => use(fn, input)
|
|
432
|
+
q.fetch = (input?: unknown) => fetchQuery(fn, input)
|
|
433
|
+
q.shape = (input?: unknown) => shapeOf(fn, input)
|
|
434
|
+
q.live = (input?: unknown, options?: LiveOptions) => liveQuery(fn, input, options)
|
|
435
|
+
return Object.assign(q, { kind: 'query', $id: id, [FN]: fn })
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** A server function that writes, with its output typed by a schema. */
|
|
439
|
+
export function mutation<OS extends StandardSchemaV1, S extends StandardSchemaV1 | undefined = undefined, const A extends AuthScope | undefined = undefined>(def: ImplMutationDef<S, OS, A>): Mutation<In<S>, Out<OS>>
|
|
440
|
+
/** A server function that writes, with its output typed by `run`. */
|
|
441
|
+
export function mutation<O, S extends StandardSchemaV1 | undefined = undefined, const A extends AuthScope | undefined = undefined>(def: MutationDef<S, O, A>): Mutation<In<S>, Awaited<O>>
|
|
442
|
+
/** A server function that writes, and invalidates the queries that read what it changed. */
|
|
443
|
+
export function mutation(def: unknown): unknown {
|
|
444
|
+
const d = def as Fn['def'] & { $id?: string }
|
|
445
|
+
oneBody(d)
|
|
446
|
+
const id = d.$id ?? `m${++auto}`
|
|
447
|
+
const fn: Fn = { kind: 'mutation', $id: id, def: d }
|
|
448
|
+
fns.set(id, fn)
|
|
449
|
+
// Module-level boxes, read on the client only (a server render never shows a call in flight).
|
|
450
|
+
const { calls, error } = createRoot(() => ({ calls: state(0), error: state<unknown>(undefined) }), null)
|
|
451
|
+
const pending = createRoot(() => derived(() => calls.value > 0), null)
|
|
452
|
+
const m = (input?: unknown) => mutate(fn, input, calls, error)
|
|
453
|
+
return Object.assign(m, { kind: 'mutation', $id: id, pending, error: { get value() { return error.value } }, [FN]: fn, [SUBMIT]: (input: unknown, form: true | FormData) => mutate(fn, input, calls, error, form) })
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const FN = Symbol.for('weft.fn')
|
|
457
|
+
/** `<Form>`'s call: a mutation handle's `m(input)` with the input marked as coming from a form. */
|
|
458
|
+
export const SUBMIT = Symbol.for('weft.submit')
|
|
459
|
+
/** The definition behind a query or mutation handle (also accepts the definition). */
|
|
460
|
+
export const fnOf = (h: unknown): Fn => ((h as Record<symbol, Fn> | null)?.[FN] ?? h) as Fn
|
|
461
|
+
|
|
462
|
+
// ---- server and client calls ---------------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
const server = () => runtime.scope?.() ?? (net()?.als.getStore() as Scope | undefined)
|
|
465
|
+
/** The request scope in progress, on the server or under the fake transport. */
|
|
466
|
+
export const currentScope = server
|
|
467
|
+
/** Run `fn` with `scope` current. */
|
|
468
|
+
export const enter = <T>(scope: unknown, fn: () => T): T => (runtime.enter ? runtime.enter(scope, fn) : net() ? net()!.als.run(scope, fn) : fn())
|
|
469
|
+
|
|
470
|
+
function fetchQuery(fn: Fn, input: unknown): Promise<unknown> {
|
|
471
|
+
const scope = server()
|
|
472
|
+
if (scope) return runtime.exec!.fetchQuery(scope, fn, input).then(e => e.v)
|
|
473
|
+
if (runtime.exec && !net()) return runtime.exec.fetchQuery(runtime.exec.createScope(new Request('http://localhost/')), fn, input).then(e => e.v)
|
|
474
|
+
const e = entry(fn, input)
|
|
475
|
+
return untrack(() => fresh(e)) ? Promise.resolve(untrack(() => e.value.value)) : load(e)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function use(fn: Fn, input: unknown): Resource<unknown> {
|
|
479
|
+
if (!getOwner())
|
|
480
|
+
throw DEV ? new StartError('Q001', `\`${fn.$id}()\` (a live query) was called outside a component or scope`, `\`await ${name(fn)}.fetch(input)\` in \`load\`, handlers and server code`) : new StartError('Q001', fn.$id)
|
|
481
|
+
const read = () => (typeof input === 'function' ? (input as () => unknown)() : input)
|
|
482
|
+
return resource(() => {
|
|
483
|
+
const arg = read()
|
|
484
|
+
if (arg === skip) return undefined
|
|
485
|
+
const scope = server()
|
|
486
|
+
if (scope) return runtime.exec!.readQuery(scope, fn, arg)
|
|
487
|
+
const e = entry(fn, arg)
|
|
488
|
+
e.readers++
|
|
489
|
+
onCleanup(() => void e.readers--)
|
|
490
|
+
const v = e.value.value // tracked: a new base or layer re-runs this, and the resource settles to it in place
|
|
491
|
+
if (fresh(e)) return v
|
|
492
|
+
return load(e)
|
|
493
|
+
}, {
|
|
494
|
+
data: false,
|
|
495
|
+
shape: {
|
|
496
|
+
of: fn,
|
|
497
|
+
read() {
|
|
498
|
+
const arg = read()
|
|
499
|
+
return arg === skip ? undefined : shapeOf(fn, arg)
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Research 31 §2: this entry's value or the query's last seen, masked; else the declared shape. */
|
|
506
|
+
function shapeOf(fn: Fn, input: unknown): unknown {
|
|
507
|
+
const limit: number | undefined = (input as { limit?: number } | undefined)?.limit
|
|
508
|
+
const here = entries.get(keyOf(fn.$id, input))
|
|
509
|
+
const e = server() ? undefined : here && untrack(() => here.has.value) ? here : lastSeen.get(fn.$id)
|
|
510
|
+
if (e) return mask(untrack(() => e.value.value), limit)
|
|
511
|
+
const tree = runtime.exec ? runtime.exec.shapeOf(fn) : fn.def.shape
|
|
512
|
+
return tree === undefined ? undefined : fill(tree, limit)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** `q()` plus a subscription: its tags come from the server, and a poke is a local `cache.invalidate`. */
|
|
516
|
+
function liveQuery(fn: Fn, input: unknown, options?: LiveOptions): LiveQuery<unknown> {
|
|
517
|
+
const r = use(fn, input)
|
|
518
|
+
const scope = server()
|
|
519
|
+
if (scope) scope.live = true
|
|
520
|
+
else
|
|
521
|
+
effect(() => {
|
|
522
|
+
const arg = typeof input === 'function' ? (input as () => unknown)() : input
|
|
523
|
+
if (arg === skip) return
|
|
524
|
+
let gone = false, stop: (() => void) | undefined
|
|
525
|
+
const held = import('./live').then(m => {
|
|
526
|
+
m.use(liveApi)
|
|
527
|
+
if (!gone) stop = m.hold(fn, arg, options)
|
|
528
|
+
})
|
|
529
|
+
void track(held)
|
|
530
|
+
onCleanup(() => {
|
|
531
|
+
gone = true
|
|
532
|
+
stop?.()
|
|
533
|
+
})
|
|
534
|
+
}, () => {})
|
|
535
|
+
return Object.assign(r, { live: rt.status, liveMode: rt.mode })
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const name = (fn: Fn) => fn.$id.replace(/\..*$/, '')
|
|
539
|
+
|
|
540
|
+
// ---- the client cache -----------------------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
interface Layer { m: number; apply: (draft: unknown) => unknown; waiting?: boolean }
|
|
543
|
+
interface Entry {
|
|
544
|
+
key: string
|
|
545
|
+
id: string
|
|
546
|
+
input: unknown
|
|
547
|
+
base: State<unknown>
|
|
548
|
+
has: State<boolean>
|
|
549
|
+
stale: State<boolean>
|
|
550
|
+
layers: State<Layer[]>
|
|
551
|
+
value: Derived<unknown>
|
|
552
|
+
tags: string[]
|
|
553
|
+
readers: number
|
|
554
|
+
p?: Promise<unknown> | undefined
|
|
555
|
+
/** Invalidated while `p` was in flight: its answer may predate the write. */
|
|
556
|
+
again?: boolean
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const entries = new Map<string, Entry>()
|
|
560
|
+
/** Each query's latest entry: a skeleton for other args takes its shape (research 31 §4). */
|
|
561
|
+
const lastSeen = new Map<string, Entry>()
|
|
562
|
+
/** Objects an optimistic layer made (`cache.isOptimistic(row)`). */
|
|
563
|
+
let made = new WeakSet<object>()
|
|
564
|
+
|
|
565
|
+
function entry(fn: Fn, input: unknown): Entry {
|
|
566
|
+
if (!registered) net()
|
|
567
|
+
const key = keyOf(fn.$id, input)
|
|
568
|
+
let e = entries.get(key)
|
|
569
|
+
if (!e) {
|
|
570
|
+
// Owned by no component: the entry outlives its readers (the next reader finds it seeded).
|
|
571
|
+
e = createRoot(() => {
|
|
572
|
+
const base = state<unknown>(undefined), has = state(false), dated = state(false), layers = state<Layer[]>([])
|
|
573
|
+
const value = derived(() => {
|
|
574
|
+
let v = base.value
|
|
575
|
+
if (!has.value) return v
|
|
576
|
+
for (const l of layers.value) v = produce(v, l.apply)
|
|
577
|
+
return v
|
|
578
|
+
})
|
|
579
|
+
return { key, id: fn.$id, input, base, has, stale: dated, layers, value, tags: [], readers: 0 }
|
|
580
|
+
}, null)
|
|
581
|
+
entries.set(key, e)
|
|
582
|
+
}
|
|
583
|
+
return e
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const fresh = (e: Entry) => e.has.value && !e.stale.value
|
|
587
|
+
|
|
588
|
+
function load(e: Entry): Promise<unknown> {
|
|
589
|
+
return (e.p ??= transport().query(e.id, e.input).then(
|
|
590
|
+
a => {
|
|
591
|
+
e.p = undefined
|
|
592
|
+
if (a.r !== undefined) {
|
|
593
|
+
go(a.r, true)
|
|
594
|
+
redirect(a.r)
|
|
595
|
+
}
|
|
596
|
+
setBase(e, a.v, a.t ?? [])
|
|
597
|
+
if (e.again) {
|
|
598
|
+
e.again = false
|
|
599
|
+
untrack(() => { e.stale.value = true }) // readers refetch: this answer may be older than the poke
|
|
600
|
+
}
|
|
601
|
+
return untrack(() => e.value.value)
|
|
602
|
+
},
|
|
603
|
+
err => {
|
|
604
|
+
e.p = undefined
|
|
605
|
+
throw err
|
|
606
|
+
},
|
|
607
|
+
))
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** A server value lands: shared with the old base, layers of settled calls dropped, readers updated in place. */
|
|
611
|
+
function setBase(e: Entry, v: unknown, tags: string[]) {
|
|
612
|
+
untrack(() => {
|
|
613
|
+
e.tags = tags
|
|
614
|
+
e.base.value = share(e.base.value, v)
|
|
615
|
+
e.has.value = true
|
|
616
|
+
e.stale.value = false
|
|
617
|
+
lastSeen.set(e.id, e)
|
|
618
|
+
if (e.layers.value.some(l => l.waiting)) e.layers.value = e.layers.value.filter(l => !l.waiting)
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Seed the cache from the server (the page's `#weft-cache`, a navigation's `c`, a mutation's refreshes). */
|
|
623
|
+
export function seed(list: Wire[] | null | undefined) {
|
|
624
|
+
for (const [id, input, v, tags] of list ?? []) setBase(entry(fns.get(id) ?? ({ $id: id } as Fn), input), v, tags)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
/** Mark entries with any of `tags` stale: entries with readers refetch now, the rest on their next read. */
|
|
629
|
+
function staleTags(tags: string[], except: Set<string> = new Set()) {
|
|
630
|
+
if (!tags.length) return
|
|
631
|
+
untrack(() => {
|
|
632
|
+
for (const e of entries.values()) {
|
|
633
|
+
if (except.has(e.key) || !e.tags.some(t => tags.includes(t))) continue
|
|
634
|
+
e.stale.value = true
|
|
635
|
+
if (e.p) e.again = true
|
|
636
|
+
}
|
|
637
|
+
})
|
|
638
|
+
client.invalidated?.(tags)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function mutate(fn: Fn, input: unknown, calls: State<number>, error: State<unknown>, form?: true | FormData): Promise<unknown> {
|
|
642
|
+
const scope = server()
|
|
643
|
+
if (scope) return (await runtime.exec!.runMutation(scope, fn, input, [], !!form)).v
|
|
644
|
+
const m = ++callId
|
|
645
|
+
const touched = new Set<Entry>()
|
|
646
|
+
untrack(() => calls.value++)
|
|
647
|
+
if (fn.def.optimistic) untrack(() => fn.def.optimistic!({ input }, overlay(m, touched)))
|
|
648
|
+
const refresh = [...entries.values()].filter(e => e.readers > 0).map(e => [e.id, e.input] as [string, unknown])
|
|
649
|
+
const drop = (e: Entry) => { e.layers.value = e.layers.value.filter(l => l.m !== m) }
|
|
650
|
+
try {
|
|
651
|
+
const a = await transport().mutate(fn.$id, input, refresh, form)
|
|
652
|
+
let out: unknown
|
|
653
|
+
untrack(() => {
|
|
654
|
+
const refreshed = new Set<string>()
|
|
655
|
+
for (const w of a.c ?? []) {
|
|
656
|
+
const e = entry(fns.get(w[0]) ?? ({ $id: w[0] } as Fn), w[1])
|
|
657
|
+
setBase(e, w[2], w[3])
|
|
658
|
+
refreshed.add(e.key)
|
|
659
|
+
}
|
|
660
|
+
for (const e of touched) {
|
|
661
|
+
if (refreshed.has(e.key)) drop(e)
|
|
662
|
+
else {
|
|
663
|
+
// S011: the server sent no fresh value for an entry this call predicted. Keep the layer until the entry's
|
|
664
|
+
// next value (refetched now) instead of flashing back to the old one.
|
|
665
|
+
if (DEV && !e.tags.some(t => a.t?.includes(t)))
|
|
666
|
+
console.warn(`weft: S011 \`${fn.$id}\`'s optimistic updated \`${e.key}\` (tags ${JSON.stringify(e.tags)}), but its invalidated tags ${JSON.stringify(a.t ?? [])} don't cover it: the prediction had no fresh value to land on.\n fix: add one of its tags to \`invalidates\``)
|
|
667
|
+
e.layers.value = e.layers.value.map(l => (l.m === m ? { ...l, waiting: true } : l))
|
|
668
|
+
e.stale.value = true
|
|
669
|
+
if (!e.readers) load(e).catch(() => {})
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
staleTags(a.t ?? [], refreshed)
|
|
673
|
+
error.value = undefined
|
|
674
|
+
out = a.v
|
|
675
|
+
})
|
|
676
|
+
if (a.r !== undefined) { if (a.x) location.assign(a.r); else go(a.r) }
|
|
677
|
+
return out
|
|
678
|
+
} catch (err) {
|
|
679
|
+
untrack(() => {
|
|
680
|
+
for (const e of touched) drop(e) // only this call's prediction goes
|
|
681
|
+
error.value = err
|
|
682
|
+
})
|
|
683
|
+
throw err
|
|
684
|
+
} finally {
|
|
685
|
+
untrack(() => calls.value--)
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
let callId = 0
|
|
689
|
+
|
|
690
|
+
/** The `cache` an `optimistic` gets: `update` and `set` add this call's layers instead of writing the base. */
|
|
691
|
+
function overlay(m: number, touched: Set<Entry>): OptimisticCache {
|
|
692
|
+
const push = (q: unknown, input: unknown, apply: Layer['apply']) => {
|
|
693
|
+
const e = entry(fnOf(q), input)
|
|
694
|
+
touched.add(e)
|
|
695
|
+
e.layers.value = [...e.layers.value, { m, apply }]
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
...cache,
|
|
699
|
+
update: (q, input, recipe) => push(q, input, recipe as Layer['apply']),
|
|
700
|
+
set: (q, input, value) => push(q, input, () => value),
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** A query handle, whatever its input: what the cache needs of one. */
|
|
705
|
+
export type QueryRef<O> = Pick<Query<void, O>, 'kind' | '$id'> & { fetch(...args: never[]): Promise<O> }
|
|
706
|
+
|
|
707
|
+
/** The cache as an `optimistic` sees it. */
|
|
708
|
+
export interface OptimisticCache {
|
|
709
|
+
/** Change a query's cached value with a draft (copy-on-write: untouched rows keep their identity). */
|
|
710
|
+
update<O>(q: QueryRef<O>, input: unknown, recipe: (draft: O) => O | void): void
|
|
711
|
+
set<O>(q: QueryRef<O>, input: unknown, value: O): void
|
|
712
|
+
get<O>(q: QueryRef<O>, input?: unknown): O | undefined
|
|
713
|
+
/** The value (a row) was made by a pending optimistic layer. */
|
|
714
|
+
isOptimistic(value: unknown): boolean
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* The client cache. In `optimistic`, `update`/`set` add layers; elsewhere they write the base (until the next server
|
|
719
|
+
* value). `invalidate` marks tags stale (readers refetch).
|
|
720
|
+
*/
|
|
721
|
+
export const cache: OptimisticCache & { invalidate(...tags: string[]): Promise<void> | undefined; clear(): void } = {
|
|
722
|
+
update(q, input, recipe) {
|
|
723
|
+
const e = entry(fnOf(q), input)
|
|
724
|
+
if (untrack(() => e.has.value)) setBase(e, produce(untrack(() => e.base.value), recipe as Layer['apply']), e.tags)
|
|
725
|
+
},
|
|
726
|
+
set(q, input, value) {
|
|
727
|
+
const e = entry(fnOf(q), input)
|
|
728
|
+
setBase(e, value, e.tags)
|
|
729
|
+
},
|
|
730
|
+
get<O>(q: QueryRef<O>, input?: unknown) {
|
|
731
|
+
const e = entries.get(keyOf(fnOf(q).$id, input))
|
|
732
|
+
return e && (untrack(() => e.value.value) as O)
|
|
733
|
+
},
|
|
734
|
+
isOptimistic: v => !!v && typeof v === 'object' && made.has(v),
|
|
735
|
+
invalidate(...tags) {
|
|
736
|
+
const scope = server()
|
|
737
|
+
if (scope) return runtime.exec!.invalidate(scope, tags)
|
|
738
|
+
staleTags(tags)
|
|
739
|
+
return undefined
|
|
740
|
+
},
|
|
741
|
+
clear() {
|
|
742
|
+
entries.clear()
|
|
743
|
+
lastSeen.clear()
|
|
744
|
+
made = new WeakSet()
|
|
745
|
+
},
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if ((import.meta as ImportMeta & { hot?: unknown }).hot) client.refreshed = () => untrack(() => { for (const e of entries.values()) e.stale.value = true })
|
|
749
|
+
|
|
750
|
+
/** Server: drop `tags` for this response and the shared cache. Client: refetch what reads them. */
|
|
751
|
+
export const invalidate = (...tags: string[]) => cache.invalidate(...tags)
|
|
752
|
+
|
|
753
|
+
// ---- transports ---------------------------------------------------------------------------------------------------
|
|
754
|
+
|
|
755
|
+
function transport(): Transport {
|
|
756
|
+
if (runtime.transport) return runtime.transport
|
|
757
|
+
if (net()) return inProcess
|
|
758
|
+
return http
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const answer = async (res: Response): Promise<Answer> => {
|
|
762
|
+
const body = await res.json().catch(() => ({ e: { message: `server function answered ${res.status}` } }))
|
|
763
|
+
if (body.e) throw new FnError(body.e, res.status)
|
|
764
|
+
return body
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** The browser's: GET for queries, POST + JSON + `x-weft` for mutations (research 27 §3). */
|
|
768
|
+
export const http: Transport = {
|
|
769
|
+
query: (id, input) => fetch(`/_weft/fn/${id}${input == null ? '' : `?i=${encodeURIComponent(stable(input))}`}`, { headers: { accept: 'application/json' } }).then(answer),
|
|
770
|
+
mutate(id, input, f, form) {
|
|
771
|
+
const headers: Record<string, string> = { accept: 'application/json', 'x-weft': '1', ...(runtime.conn && { 'x-weft-conn': runtime.conn }) }
|
|
772
|
+
if (form instanceof FormData) form.set('$r', JSON.stringify(f))
|
|
773
|
+
else headers['content-type'] = 'application/json'
|
|
774
|
+
return fetch(`/_weft/fn/${id}`, { method: 'POST', headers, body: form instanceof FormData ? form : JSON.stringify({ i: input, f, ...(form && { c: 1 }) }) }).then(answer)
|
|
775
|
+
},
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* @weft-js/core/test's fake transport: the server's executor in process, with the controls @weft-js/core/test's `net` sets (latency,
|
|
780
|
+
* failures, replaced functions, offline), and a JSON round trip both ways so values arrive as they would over HTTP.
|
|
781
|
+
*/
|
|
782
|
+
export const fakeScope = (exec: typeof Exec, n: NetControl, request: Request) =>
|
|
783
|
+
exec.createScope(request, { context: () => n.ctx ?? {}, handlers: n.handlers, dev: true, extensions: n.extensions, onDiagnostic: d => console.warn(`weft: ${d.message.startsWith(d.code) ? '' : d.code + ' '}${d.message}`) })
|
|
784
|
+
|
|
785
|
+
type Call = (exec: typeof Exec, scope: Scope, fn: Fn) => Promise<Answer>
|
|
786
|
+
|
|
787
|
+
const inProcess: Transport = {
|
|
788
|
+
query: (id, input) => fake(id, 'query', input, (exec, scope, fn) => exec.fetchQuery(scope, fn, input).then(e => ({ v: e.v, t: e.tags }))),
|
|
789
|
+
mutate: (id, input, f, form) => fake(id, 'mutation', input, (exec, scope, fn) => exec.runMutation(scope, fn, input, f, !!form)),
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function fake(id: string, kind: string, input: unknown, call: Call): Promise<Answer> {
|
|
793
|
+
return track(fakeCall(net()!, id, kind, input, call))
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** `net.settle()` waits for `p`. */
|
|
797
|
+
export function track<T>(p: Promise<T>): Promise<T> {
|
|
798
|
+
const n = net()
|
|
799
|
+
if (!n) return p
|
|
800
|
+
n.inflight.add(p)
|
|
801
|
+
const over = () => void n.inflight.delete(p)
|
|
802
|
+
p.then(over, over)
|
|
803
|
+
return p
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function fakeCall(n: NetControl, id: string, kind: string, input: unknown, call: Call): Promise<Answer> {
|
|
807
|
+
n.log.push({ id, kind, input })
|
|
808
|
+
const exec = (runtime.exec ??= await import('./exec'))
|
|
809
|
+
if (n.latency) await new Promise(ok => { setTimeout(ok, n.latency) })
|
|
810
|
+
if (!n.online()) throw Object.assign(new Error(`offline: ${id} wasn't sent`), { name: 'OfflineError' })
|
|
811
|
+
const fails = n.fails.get(id)
|
|
812
|
+
if (fails?.length) throw fails.shift()
|
|
813
|
+
const fn = fns.get(id)
|
|
814
|
+
if (!fn) throw new FnError({ code: 'Q009', message: `Q009: no server function \`${id}\`` }, 404)
|
|
815
|
+
const scope = fakeScope(exec, n, new Request(`http://localhost/_weft/fn/${id}`, { method: kind === 'query' ? 'GET' : 'POST', headers: runtime.conn ? { 'x-weft-conn': runtime.conn } : {} }))
|
|
816
|
+
try {
|
|
817
|
+
return JSON.parse(JSON.stringify(await n.als.run(scope, () => call(exec, scope, fn))))
|
|
818
|
+
} catch (e) {
|
|
819
|
+
if (isRedirect(e)) return { r: e.href }
|
|
820
|
+
throw e instanceof StartError ? new FnError({ code: e.code, message: e.message, issues: e.issues, data: e.data }, e.status ?? 500) : e
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ---- structural sharing and drafts ------------------------------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
type Obj = Record<PropertyKey, unknown>
|
|
827
|
+
|
|
828
|
+
const plain = (v: unknown): v is Obj =>
|
|
829
|
+
!!v && typeof v === 'object' && (Array.isArray(v) || Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null)
|
|
830
|
+
|
|
831
|
+
/** `next`, with every subtree deep-equal to `prev`'s replaced by `prev`'s (TanStack's replaceEqualDeep). */
|
|
832
|
+
export function share<T>(prev: unknown, next: T): T {
|
|
833
|
+
if (prev === next || !plain(prev) || !plain(next) || Array.isArray(prev) !== Array.isArray(next)) return next
|
|
834
|
+
const keys = Object.keys(next)
|
|
835
|
+
let same = Object.keys(prev).length === keys.length
|
|
836
|
+
const out = (Array.isArray(next) ? [] : {}) as Obj
|
|
837
|
+
for (const k of keys) {
|
|
838
|
+
out[k] = share(prev[k], next[k])
|
|
839
|
+
if (out[k] !== prev[k]) same = false
|
|
840
|
+
}
|
|
841
|
+
return (same ? prev : out) as T
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
interface Draft { b: Obj; c?: Obj; px: Obj; done?: Obj }
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Run `recipe` on a copy-on-write draft of `base`: only objects it writes (and their parents) are copied. A recipe
|
|
848
|
+
* may instead return a new value. The copies are remembered for `cache.isOptimistic`.
|
|
849
|
+
*/
|
|
850
|
+
export function produce<T>(base: T, recipe: (draft: T) => T | void): T {
|
|
851
|
+
if (!plain(base)) {
|
|
852
|
+
const r = recipe(base)
|
|
853
|
+
return r === undefined ? base : r
|
|
854
|
+
}
|
|
855
|
+
const nodes = new Map<object, Draft>()
|
|
856
|
+
const byProxy = new WeakMap<object, Draft>()
|
|
857
|
+
const unwrap = (v: unknown) => (v && typeof v === 'object' && byProxy.has(v) ? byProxy.get(v)!.b : v)
|
|
858
|
+
const cur = (n: Draft) => n.c ?? n.b
|
|
859
|
+
const own = (n: Draft) => (n.c ??= (Array.isArray(n.b) ? n.b.slice() : { ...n.b }) as Obj)
|
|
860
|
+
const node = (b: Obj): Draft => {
|
|
861
|
+
let n = nodes.get(b)
|
|
862
|
+
if (n) return n
|
|
863
|
+
const self: Draft = n = { b, px: b }
|
|
864
|
+
n.px = new Proxy((Array.isArray(b) ? [] : {}) as Obj, {
|
|
865
|
+
get: (_, k) => {
|
|
866
|
+
const v = cur(self)[k]
|
|
867
|
+
return plain(v) ? node(v).px : v
|
|
868
|
+
},
|
|
869
|
+
set: (_, k, v) => {
|
|
870
|
+
v = unwrap(v)
|
|
871
|
+
if (plain(v) && !nodes.has(v)) made.add(v) // a new object (a pushed row) is optimistic too
|
|
872
|
+
own(self)[k] = v
|
|
873
|
+
return true
|
|
874
|
+
},
|
|
875
|
+
deleteProperty: (_, k) => (delete own(self)[k], true),
|
|
876
|
+
has: (_, k) => k in cur(self),
|
|
877
|
+
ownKeys: () => Reflect.ownKeys(cur(self)),
|
|
878
|
+
getOwnPropertyDescriptor: (t, k) => {
|
|
879
|
+
const d = Reflect.getOwnPropertyDescriptor(cur(self), k)
|
|
880
|
+
if (d && k === 'length' && Array.isArray(t)) return { ...d, configurable: false }
|
|
881
|
+
return d && { ...d, configurable: true, value: (self.px)[k] }
|
|
882
|
+
},
|
|
883
|
+
})
|
|
884
|
+
nodes.set(b, n)
|
|
885
|
+
byProxy.set(n.px, n)
|
|
886
|
+
return n
|
|
887
|
+
}
|
|
888
|
+
// The result: copies where something changed, the original objects everywhere else.
|
|
889
|
+
const fin = (n: Draft): Obj => {
|
|
890
|
+
if (n.done !== undefined) return n.done
|
|
891
|
+
n.done = n.b // cycles stop here
|
|
892
|
+
const src = cur(n)
|
|
893
|
+
let out = n.c
|
|
894
|
+
for (const k of Object.keys(src)) {
|
|
895
|
+
const v = src[k]
|
|
896
|
+
if (!plain(v) || !nodes.has(v)) continue
|
|
897
|
+
const f = fin(nodes.get(v)!)
|
|
898
|
+
if (f !== v) (out ??= own(n))[k] = f
|
|
899
|
+
}
|
|
900
|
+
if (out) made.add(out)
|
|
901
|
+
return (n.done = out ?? n.b)
|
|
902
|
+
}
|
|
903
|
+
const deep = (v: unknown): unknown => {
|
|
904
|
+
if (v && typeof v === 'object' && byProxy.has(v)) return fin(byProxy.get(v)!)
|
|
905
|
+
if (Array.isArray(v)) return v.map(deep)
|
|
906
|
+
if (plain(v)) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deep(x)]))
|
|
907
|
+
return v
|
|
908
|
+
}
|
|
909
|
+
const root = node(base)
|
|
910
|
+
const r = recipe(root.px as T)
|
|
911
|
+
return (r === undefined || r === root.px ? fin(root) : deep(r)) as T
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** What `live.ts` reads from here; see its header. */
|
|
915
|
+
export const liveApi = { cache, currentScope, fakeScope, fns, FnError, keyOf, net, rt, runtime, skip, StartError, track }
|
|
916
|
+
/** What `live.ts` is handed, instead of a static import that would close a cycle. */
|
|
917
|
+
export type LiveApi = typeof liveApi
|
|
918
|
+
|
|
919
|
+
// ---- start-up ----------------------------------------------------------------------------------------------------
|
|
920
|
+
|
|
921
|
+
// In the browser, as the first route chunk that uses a query loads (before hydration): the page's entries, and
|
|
922
|
+
// whatever a navigation brought before this module loaded.
|
|
923
|
+
if (typeof document !== 'undefined' && !runtime.scope) {
|
|
924
|
+
client.seed = seed
|
|
925
|
+
seed(client.seeds)
|
|
926
|
+
client.seeds = undefined
|
|
927
|
+
const el = document.getElementById('weft-cache')
|
|
928
|
+
if (el) seed(JSON.parse(el.textContent))
|
|
929
|
+
}
|