@weft-js/start 0.1.1 → 0.1.3
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/README.md +1278 -12
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/README.md +322 -0
- package/crate/rust-toolchain.toml +3 -0
- package/crate/src/build/emit.rs +103 -0
- package/crate/src/build.rs +367 -0
- package/crate/src/ctx.rs +186 -0
- package/crate/src/envelope.rs +163 -0
- package/crate/src/functions.rs +173 -0
- package/crate/src/lib.rs +118 -0
- package/crate/src/native.rs +212 -0
- package/crate/src/otel.rs +62 -0
- package/crate/src/server.rs +203 -0
- package/package.json +46 -37
- package/src/app.mjs +38 -8
- package/src/asset-receipt.d.mts +13 -0
- package/src/asset-receipt.mjs +61 -0
- package/src/auth/better-auth.ts +215 -17
- package/src/auth/drizzle.ts +1 -1
- package/src/auth.ts +21 -6
- package/src/boot.ts +19 -8
- package/src/bus/postgres.ts +32 -19
- package/src/bus.ts +7 -4
- package/src/{capture.mjs → capture.ts} +34 -1
- package/src/changes-sql.d.mts +8 -2
- package/src/changes-sql.mjs +26 -10
- package/src/changes.ts +165 -66
- package/src/channel/connections.ts +216 -0
- package/src/channel/socket.ts +396 -0
- package/src/channel-fake.ts +19 -4
- package/src/channel.ts +120 -492
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +144 -157
- package/src/data/cache.ts +354 -0
- package/src/data/definitions.ts +94 -0
- package/src/data/draft.ts +104 -0
- package/src/data/foundation.ts +95 -0
- package/src/data/live-api.ts +22 -0
- package/src/data/mutation.ts +138 -0
- package/src/data/primitives.ts +135 -0
- package/src/data/query.ts +106 -0
- package/src/data/transport.ts +121 -0
- package/src/data/types.ts +303 -0
- package/src/data.ts +69 -894
- package/src/db/bun-sqlite.d.ts +4 -1
- package/src/db/changes-mysql.ts +61 -21
- package/src/db/changes-postgres.ts +197 -118
- package/src/db/changes-sqlite.ts +131 -64
- package/src/db/core.ts +38 -13
- package/src/db/index.ts +20 -4
- package/src/db/mysql.ts +22 -6
- package/src/db/postgres.ts +49 -13
- package/src/db/sqlite.ts +53 -27
- package/src/derive.d.mts +4 -1
- package/src/derive.mjs +136 -39
- package/src/dev-runtime.mjs +8 -4
- package/src/exec/errors.ts +57 -0
- package/src/exec/landing.ts +66 -0
- package/src/exec/scope.ts +174 -0
- package/src/exec/touch.ts +80 -0
- package/src/exec/validation.ts +176 -0
- package/src/exec.ts +191 -341
- package/src/extension.ts +8 -2
- package/src/first-paint.ts +26 -9
- package/src/form/value.ts +15 -0
- package/src/form.tsx +132 -43
- package/src/hub/channels.ts +102 -0
- package/src/hub/connections.ts +147 -0
- package/src/hub/delivery.ts +62 -0
- package/src/hub/feed.ts +168 -0
- package/src/hub/presence.ts +64 -0
- package/src/hub/route.ts +158 -0
- package/src/hub/subscriptions.ts +212 -0
- package/src/hub/topic.ts +98 -0
- package/src/hub/tracked.ts +12 -0
- package/src/hub/types.ts +190 -0
- package/src/hub/view.ts +147 -0
- package/src/hub.ts +132 -758
- package/src/index.ts +84 -10
- package/src/inline.mjs +356 -0
- package/src/inspect/compare.mjs +162 -0
- package/src/inspect/routes.mjs +154 -0
- package/src/inspect/samples.mjs +59 -0
- package/src/inspect/url.mjs +2 -0
- package/src/inspect.mjs +146 -187
- package/src/link/election.ts +178 -0
- package/src/link/lines.ts +27 -0
- package/src/link.ts +90 -166
- package/src/list-drizzle.ts +104 -18
- package/src/list.ts +127 -36
- package/src/listen.ts +32 -6
- package/src/live/api.ts +18 -0
- package/src/live/backoff.ts +6 -0
- package/src/live/frames.ts +42 -0
- package/src/live/read.ts +77 -0
- package/src/live/sub-types.ts +24 -0
- package/src/live/transport.ts +86 -0
- package/src/live/types.ts +52 -0
- package/src/live-fake.ts +20 -5
- package/src/live-target.mjs +112 -37
- package/src/live.ts +116 -225
- package/src/media.ts +8 -1
- package/src/migrations.mjs +14 -6
- package/src/mux.ts +161 -64
- package/src/og/DefaultCard.tsx +40 -9
- package/src/og/fonts.mjs +16 -8
- package/src/og/images.mjs +43 -22
- package/src/og/index.mjs +119 -43
- package/src/og/native.mjs +11 -4
- package/src/range.mjs +15 -3
- package/src/router.ts +56 -6
- package/src/routes.mjs +73 -22
- package/src/run/admission.ts +80 -0
- package/src/run/boot.tsx +115 -0
- package/src/run/cache-presentation.ts +67 -0
- package/src/run/cache.ts +254 -0
- package/src/run/helpers.ts +197 -0
- package/src/run/listeners.ts +155 -0
- package/src/run/navigation.ts +382 -0
- package/src/run/page-skeleton.tsx +35 -0
- package/src/run/prefetch.ts +128 -0
- package/src/run/retain-view.ts +18 -0
- package/src/run/shape-preparation.ts +97 -0
- package/src/run/shape-styles.ts +72 -0
- package/src/run/source-context/pin.ts +124 -0
- package/src/run/source-context.ts +311 -0
- package/src/run.tsx +50 -178
- package/src/rust/common.ts +163 -0
- package/src/rust/native.ts +282 -0
- package/src/rust.ts +103 -229
- package/src/server/card.tsx +65 -0
- package/src/server/compression.ts +115 -0
- package/src/server/core.ts +70 -0
- package/src/server/dispatch.ts +70 -0
- package/src/server/form.ts +85 -0
- package/src/server/functions.ts +129 -0
- package/src/server/handler.ts +39 -0
- package/src/server/head.ts +96 -0
- package/src/server/media.ts +60 -0
- package/src/server/navigation-cache.ts +80 -0
- package/src/server/navigation.tsx +85 -0
- package/src/server/page-cold.ts +95 -0
- package/src/server/page-stream.tsx +181 -0
- package/src/server/page-types.ts +51 -0
- package/src/server/page-warm.ts +130 -0
- package/src/server/page.ts +69 -0
- package/src/server/payload.ts +89 -0
- package/src/server/prerender.ts +60 -0
- package/src/server/preview.tsx +67 -0
- package/src/server/render.ts +167 -0
- package/src/server/request.ts +98 -0
- package/src/server/resolver.ts +228 -0
- package/src/server/routes.ts +150 -0
- package/src/server/services.ts +69 -0
- package/src/server/text.ts +29 -0
- package/src/server/types.ts +160 -0
- package/src/server.tsx +82 -971
- package/src/shape.mjs +30 -6
- package/src/share/display.ts +50 -0
- package/src/share/network.ts +119 -0
- package/src/share/receive.ts +70 -0
- package/src/share/state.ts +30 -0
- package/src/share/tab-queue.ts +28 -0
- package/src/share-server.ts +128 -43
- package/src/share.ts +103 -209
- package/src/sheets.mjs +30 -18
- package/src/sidecar/build.mjs +170 -0
- package/src/sidecar/cargo.mjs +164 -0
- package/src/sidecar/common.mjs +17 -0
- package/src/sidecar/dev-build.mjs +91 -0
- package/src/sidecar/dev-watch.mjs +88 -0
- package/src/sidecar/manifest.mjs +19 -0
- package/src/sidecar/process.mjs +58 -0
- package/src/sidecar/refresh.mjs +19 -0
- package/src/sidecar.mjs +75 -416
- package/src/socket/check.ts +50 -0
- package/src/socket/deliver.ts +16 -0
- package/src/socket/publish.ts +56 -0
- package/src/socket/rooms.ts +41 -0
- package/src/socket/route.ts +79 -0
- package/src/socket/shared.ts +26 -0
- package/src/socket/types.ts +48 -0
- package/src/socket.ts +205 -194
- package/src/stream.ts +10 -1
- package/src/verify/app.ts +39 -0
- package/src/verify/cleanup.ts +27 -0
- package/src/verify/database.ts +11 -0
- package/src/verify/live.ts +63 -0
- package/src/verify/notify.ts +35 -0
- package/src/verify/skip.ts +19 -0
- package/src/verify/support.ts +149 -0
- package/src/verify/timing.ts +15 -0
- package/src/verify/trigger.ts +14 -0
- package/src/verify.ts +77 -176
- package/src/view.tsx +96 -11
- package/src/vite/asset-bytes.mjs +95 -0
- package/src/vite/assets.mjs +117 -0
- package/src/vite/config-hooks.mjs +166 -0
- package/src/vite/dev-hooks.mjs +103 -0
- package/src/vite/dev.mjs +102 -0
- package/src/vite/entries.mjs +145 -0
- package/src/vite/module-hooks.mjs +288 -0
- package/src/vite/route-modules.mjs +289 -0
- package/src/vite/server-functions.mjs +222 -0
- package/src/vite/shape-entry-css.mjs +187 -0
- package/src/vite/shape-override.mjs +226 -0
- package/src/vite/shared.mjs +51 -0
- package/src/vite/sheets.mjs +92 -0
- package/src/vite/state.mjs +89 -0
- package/src/vite/syntax.mjs +139 -0
- package/src/vite.mjs +152 -853
- package/src/capture.d.mts +0 -1
package/src/auth/better-auth.ts
CHANGED
|
@@ -1,12 +1,99 @@
|
|
|
1
1
|
// Better Auth as weft's default auth provider (research 28 §3, 34 §5). `src/auth.server.ts` default-exports `createAuth(…)`.
|
|
2
2
|
// No client code: sign-in is a mutation, so nothing of Better Auth ships to the browser.
|
|
3
3
|
|
|
4
|
-
import { betterAuth, type BetterAuthOptions } from 'better-auth'
|
|
5
|
-
import {
|
|
4
|
+
import { betterAuth, type BetterAuthOptions, type BetterAuthPlugin } from 'better-auth'
|
|
5
|
+
import { createAuthMiddleware } from 'better-auth/api'
|
|
6
|
+
import { expireCookie } from 'better-auth/cookies'
|
|
7
|
+
import { jwt, type JwtOptions } from 'better-auth/plugins/jwt'
|
|
8
|
+
import { CLIENT_IP, currentScope, fnOf, StartError } from '../data'
|
|
6
9
|
import type { DbHandle } from '../db/core'
|
|
7
10
|
|
|
8
11
|
const SESSION = /(?:^|;\s*)(?:__Secure-)?[\w.-]*session_token=/
|
|
9
|
-
const
|
|
12
|
+
const BEARER = /^Bearer\s+(\S+)$/i
|
|
13
|
+
const CHUNK = /^(?:__Secure-)?[\w.-]*session_data\.\d+=/
|
|
14
|
+
/** A socket or stream keeps its `ctx` past this request, and an extension's route is its own, so they never read the cache. */
|
|
15
|
+
const HELD = /^\/_weft\/(rt|ws|x\/)/
|
|
16
|
+
const FN = '/_weft/fn/'
|
|
17
|
+
const ANONYMOUS = { user: null, session: null }
|
|
18
|
+
const TABLES = ['user', 'session', 'account', 'verification', 'jwks']
|
|
19
|
+
|
|
20
|
+
/** A cache too big for one cookie is no cache: that session just reads the database. An expired chunk still goes out. */
|
|
21
|
+
const oversized = (line: string) =>
|
|
22
|
+
CHUNK.test(line) && !/max-age=0/i.test(line) && !/^[^=]+=;/.test(line)
|
|
23
|
+
|
|
24
|
+
/** Better Auth's own routes follow the rule `session()` does: only reading who you are can answer from the cache. */
|
|
25
|
+
const cacheRules: BetterAuthPlugin = {
|
|
26
|
+
id: 'weft-session-cache',
|
|
27
|
+
hooks: {
|
|
28
|
+
// `/token` above all: a revoked session mustn't mint a 15-minute token from a cookie it still holds.
|
|
29
|
+
before: [
|
|
30
|
+
{
|
|
31
|
+
matcher: (c) => c.path !== '/get-session',
|
|
32
|
+
handler: createAuthMiddleware((c) =>
|
|
33
|
+
Promise.resolve({ context: { query: { ...c.query, disableCookieCache: true } } }),
|
|
34
|
+
),
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
// With `revokeOtherSessions` this response already carries the new session's cookies.
|
|
38
|
+
after: [
|
|
39
|
+
{
|
|
40
|
+
matcher: (c) => c.path === '/change-password',
|
|
41
|
+
handler: createAuthMiddleware((c) =>
|
|
42
|
+
Promise.resolve(
|
|
43
|
+
void (c.context.newSession || expireCookie(c, c.context.authCookies.sessionData)),
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
// Chunks go on every request after this one: a big enough user would pass the server's header limit and be locked out with 431s.
|
|
48
|
+
{
|
|
49
|
+
matcher: () => true,
|
|
50
|
+
handler: createAuthMiddleware((c) => {
|
|
51
|
+
const out = (c.context as { responseHeaders?: Headers }).responseHeaders
|
|
52
|
+
const all = out?.getSetCookie() ?? []
|
|
53
|
+
if (out && all.some(oversized)) {
|
|
54
|
+
out.delete('set-cookie')
|
|
55
|
+
for (const line of all) if (!oversized(line)) out.append('set-cookie', line)
|
|
56
|
+
}
|
|
57
|
+
return Promise.resolve()
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `createAuth({ api })`: Better Auth's `jwt` options, and which server functions take a bearer token. */
|
|
65
|
+
export interface ApiAccess extends JwtOptions {
|
|
66
|
+
/** The server functions a bearer token may call, as a function so this module needn't load them first. No others read it. */
|
|
67
|
+
functions?: () => unknown[]
|
|
68
|
+
}
|
|
69
|
+
type Found = NonNullable<
|
|
70
|
+
Awaited<ReturnType<ReturnType<typeof betterAuth<BetterAuthOptions>>['api']['getSession']>>
|
|
71
|
+
>
|
|
72
|
+
type Session = { user: Found['user'] | null; session: Found['session'] | null }
|
|
73
|
+
/** What a token of ours carries: Better Auth's claims, the session it came from, and the user's fields. */
|
|
74
|
+
type Claims = {
|
|
75
|
+
sub: string
|
|
76
|
+
sid: string
|
|
77
|
+
iss?: string
|
|
78
|
+
aud?: string | string[]
|
|
79
|
+
exp?: number
|
|
80
|
+
iat?: number
|
|
81
|
+
} & Record<string, unknown>
|
|
82
|
+
/** The calls made here. The `jwt` plugin is optional, so `verifyJWT` isn't in the instance's own type. */
|
|
83
|
+
interface Methods {
|
|
84
|
+
verifyJWT(input: { body: { token: string } }): Promise<{ payload: Claims | null }>
|
|
85
|
+
getSession(input: {
|
|
86
|
+
headers: Headers
|
|
87
|
+
returnHeaders: true
|
|
88
|
+
query?: { disableCookieCache: boolean }
|
|
89
|
+
}): Promise<{ headers: Headers; response: Found | null }>
|
|
90
|
+
}
|
|
91
|
+
type Signed = Parameters<NonNullable<NonNullable<JwtOptions['jwt']>['definePayload']>>[0]
|
|
92
|
+
|
|
93
|
+
const buildSecret = () =>
|
|
94
|
+
Array.from(crypto.getRandomValues(new Uint8Array(32)), (x) =>
|
|
95
|
+
x.toString(16).padStart(2, '0'),
|
|
96
|
+
).join('')
|
|
10
97
|
|
|
11
98
|
/** `createAuth`: the database, the secret, and any Better Auth option on top. */
|
|
12
99
|
export interface AuthOptions<S extends Record<string, unknown>, O extends BetterAuthOptions> {
|
|
@@ -14,16 +101,102 @@ export interface AuthOptions<S extends Record<string, unknown>, O extends Better
|
|
|
14
101
|
/** The tables, Better Auth's included, for a data layer that needs them. */
|
|
15
102
|
schema?: S
|
|
16
103
|
/** 32+ characters (A001 in production). */
|
|
17
|
-
secret?: string
|
|
104
|
+
secret?: string | undefined
|
|
18
105
|
/** The rest of Better Auth's options; they win over the defaults here. */
|
|
19
106
|
options?: O
|
|
107
|
+
/** Short-lived JWTs for other services and API clients, with a public JWKS: Better Auth's `jwt` options. `false` turns it off. */
|
|
108
|
+
api?: ApiAccess | false
|
|
20
109
|
}
|
|
21
110
|
|
|
22
|
-
|
|
23
|
-
|
|
111
|
+
function sessionReader(
|
|
112
|
+
auth: {
|
|
113
|
+
$context: Promise<{
|
|
114
|
+
internalAdapter: {
|
|
115
|
+
listSessions(
|
|
116
|
+
user: string,
|
|
117
|
+
options: { onlyActiveSessions: boolean },
|
|
118
|
+
): Promise<{ id: string }[]>
|
|
119
|
+
}
|
|
120
|
+
}>
|
|
121
|
+
},
|
|
122
|
+
methods: Methods,
|
|
123
|
+
claims: { issuer?: string | undefined; audience?: string | string[] | undefined },
|
|
124
|
+
functions: (() => unknown[]) | undefined,
|
|
125
|
+
api: ApiAccess | false | undefined,
|
|
126
|
+
) {
|
|
127
|
+
/** Who a token says, for a function that takes one. A write also needs the session behind it to still be there. */
|
|
128
|
+
async function bearer(token: string, read: boolean): Promise<Session> {
|
|
129
|
+
// Unchecked, Better Auth would skip `iss` and `aud`: with no URL configured, no token is good.
|
|
130
|
+
if (!claims.issuer || !claims.audience) return ANONYMOUS
|
|
131
|
+
const { payload } = await methods.verifyJWT({ body: { token } })
|
|
132
|
+
if (!payload) return ANONYMOUS
|
|
133
|
+
if (!read) {
|
|
134
|
+
const live = await (
|
|
135
|
+
await auth.$context
|
|
136
|
+
).internalAdapter.listSessions(payload.sub, { onlyActiveSessions: true })
|
|
137
|
+
if (!live.some((s) => s.id === payload.sid)) return ANONYMOUS
|
|
138
|
+
}
|
|
139
|
+
const { iss: _iss, aud: _aud, exp: _exp, iat: _iat, sub, sid: _sid, ...user } = payload
|
|
140
|
+
return { user: { ...user, id: sub } as unknown as Found['user'], session: null }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const marked = (pathname: string) => {
|
|
144
|
+
if (!functions || !pathname.startsWith(FN)) return false
|
|
145
|
+
const id = pathname.slice(FN.length)
|
|
146
|
+
return functions().some((f) => encodeURIComponent((fnOf(f) as { $id: string }).$id) === id)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return async function session(request: Request): Promise<Session> {
|
|
150
|
+
const read = request.method === 'GET' || request.method === 'HEAD'
|
|
151
|
+
const { pathname } = new URL(request.url)
|
|
152
|
+
const token = request.headers.get('authorization')?.match(BEARER)?.[1]
|
|
153
|
+
if (token && api !== false && marked(pathname)) return bearer(token, read)
|
|
154
|
+
if (!SESSION.test(request.headers.get('cookie') ?? '')) return ANONYMOUS
|
|
155
|
+
const { headers, response: s } = await methods.getSession({
|
|
156
|
+
headers: request.headers,
|
|
157
|
+
returnHeaders: true,
|
|
158
|
+
...(!(read && !HELD.test(pathname)) && { query: { disableCookieCache: true } }),
|
|
159
|
+
})
|
|
160
|
+
// A fresh cache cookie, a slid expiry or a dead session's removal. Not `forward`: who's signed in hasn't changed.
|
|
161
|
+
// A socket's re-check runs inside whichever request poked it, and its cookies are that socket's, not this response's.
|
|
162
|
+
const scope = currentScope() as { request?: Request; headers?: Headers } | undefined
|
|
163
|
+
if (scope?.headers && scope.request === request)
|
|
164
|
+
for (const c of headers.getSetCookie()) scope.headers.append('set-cookie', c)
|
|
165
|
+
return { user: s?.user ?? null, session: s?.session ?? null }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function requireStore(db: { $weft: DbHandle }, options: BetterAuthOptions | undefined) {
|
|
24
170
|
const handle = db.$weft
|
|
25
|
-
if (options?.database) for (const name of
|
|
26
|
-
else
|
|
171
|
+
if (options?.database) for (const name of TABLES) handle.exempt.add(name)
|
|
172
|
+
else
|
|
173
|
+
throw new StartError(
|
|
174
|
+
'A005',
|
|
175
|
+
'Better Auth has no store',
|
|
176
|
+
"pass `options.database`: `drizzleAuth(db, schema)` from @weft-js/start/auth/drizzle, or your data layer's adapter",
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A Better Auth instance with Weft's defaults, on the store `options.database` names. */
|
|
181
|
+
export function createAuth<
|
|
182
|
+
S extends Record<string, unknown>,
|
|
183
|
+
const O extends BetterAuthOptions = {},
|
|
184
|
+
>({ db, secret, options, api }: AuthOptions<S, O>) {
|
|
185
|
+
requireStore(db, options)
|
|
186
|
+
const { functions, ...access } = api || {}
|
|
187
|
+
const origin =
|
|
188
|
+
typeof options?.baseURL === 'string' ? options.baseURL : process.env.BETTER_AUTH_URL
|
|
189
|
+
const claims = {
|
|
190
|
+
expirationTime: '15m',
|
|
191
|
+
issuer: origin,
|
|
192
|
+
audience: origin,
|
|
193
|
+
...access.jwt,
|
|
194
|
+
// Which session a token came from, so a write can ask whether it still exists.
|
|
195
|
+
definePayload: async (s: Signed) => ({
|
|
196
|
+
...((await access.jwt?.definePayload?.(s)) ?? s.user),
|
|
197
|
+
sid: s.session.id,
|
|
198
|
+
}),
|
|
199
|
+
}
|
|
27
200
|
const auth = betterAuth({
|
|
28
201
|
// A build loads this module with no environment, and in production Better Auth refuses to start without a secret: the
|
|
29
202
|
// build gets one of its own, since nothing it makes is signed. A server still needs the real one (A001).
|
|
@@ -33,26 +206,51 @@ export function createAuth<S extends Record<string, unknown>, const O extends Be
|
|
|
33
206
|
telemetry: { enabled: false },
|
|
34
207
|
emailAndPassword: { enabled: true },
|
|
35
208
|
...options,
|
|
209
|
+
// Login stays an opaque token in the database. This only spares reads of it, for a window: see `session()`.
|
|
210
|
+
session: {
|
|
211
|
+
...options?.session,
|
|
212
|
+
cookieCache: {
|
|
213
|
+
enabled: true,
|
|
214
|
+
maxAge: 300,
|
|
215
|
+
strategy: 'compact',
|
|
216
|
+
...options?.session?.cookieCache,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
// Signing a JWT on every session read would put a query back on each page, and login has no use for one.
|
|
220
|
+
plugins: [
|
|
221
|
+
...(options?.plugins ?? []),
|
|
222
|
+
cacheRules,
|
|
223
|
+
...(api === false ? [] : [jwt({ ...access, jwt: claims, disableSettingJwtHeader: true })]),
|
|
224
|
+
],
|
|
36
225
|
// The server stamps this header from the socket (server.tsx), so rate limits aren't one bucket for everyone.
|
|
37
226
|
// Its schema check runs when this module loads. On Kysely that connects, in a build, and before a fresh database has
|
|
38
227
|
// had weft's migrations. Q014 covers a schema that doesn't match, so it's off unless you turn it on.
|
|
39
|
-
advanced: {
|
|
228
|
+
advanced: {
|
|
229
|
+
...options?.advanced,
|
|
230
|
+
database: { validateSchema: false, ...options?.advanced?.database },
|
|
231
|
+
ipAddress: { ipAddressHeaders: [CLIENT_IP], ...options?.advanced?.ipAddress },
|
|
232
|
+
},
|
|
40
233
|
})
|
|
234
|
+
const methods = auth.api as unknown as Methods
|
|
235
|
+
|
|
41
236
|
return Object.assign(auth, {
|
|
42
237
|
basePath: options?.basePath,
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
238
|
+
/**
|
|
239
|
+
* With no session cookie it's `null` without a query, so anonymous pages don't touch the database. A page or query
|
|
240
|
+
* reads the signed cookie cache; a mutation (not GET) and anything that outlives the request read the database, so a
|
|
241
|
+
* revoked session may be shown a page for the cache's window but can't write.
|
|
242
|
+
*/
|
|
243
|
+
session: sessionReader(auth, methods, claims, functions, api),
|
|
49
244
|
$weft: {
|
|
50
245
|
/** On the first request (never in a build, which holds no secrets). */
|
|
51
246
|
check() {
|
|
52
247
|
if (process.env.NODE_ENV === 'production' && (secret?.length ?? 0) < 32)
|
|
53
|
-
throw new StartError(
|
|
248
|
+
throw new StartError(
|
|
249
|
+
'A001',
|
|
250
|
+
'the auth secret is missing or under 32 characters',
|
|
251
|
+
"set `BETTER_AUTH_SECRET` (`openssl rand -base64 32`) in the server's environment",
|
|
252
|
+
)
|
|
54
253
|
},
|
|
55
254
|
},
|
|
56
255
|
})
|
|
57
256
|
}
|
|
58
|
-
|
package/src/auth/drizzle.ts
CHANGED
|
@@ -9,7 +9,7 @@ const PROVIDER = { sqlite: 'sqlite', postgresql: 'pg', mysql: 'mysql' } as const
|
|
|
9
9
|
|
|
10
10
|
/** Better Auth's store on a Weft database, for `createAuth`'s `options.database`. */
|
|
11
11
|
export function drizzleAuth(db: { $weft: DbHandle }, schema?: Record<string, unknown>) {
|
|
12
|
-
for (const name of ['user', 'session', 'account', 'verification']) {
|
|
12
|
+
for (const name of ['user', 'session', 'account', 'verification', 'jwks']) {
|
|
13
13
|
const t = schema?.[name]
|
|
14
14
|
if (isTable(t)) db.$weft.exempt.add(getTableName(t))
|
|
15
15
|
}
|
package/src/auth.ts
CHANGED
|
@@ -8,10 +8,13 @@ import { FnError, type Context } from './data'
|
|
|
8
8
|
export interface AuthProvider<U = unknown, S = unknown> {
|
|
9
9
|
/** Serves everything under `basePath`. */
|
|
10
10
|
handler(request: Request): Promise<Response> | Response
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Who this request is. Anonymous is `{ user: null, session: null }`, and should cost no query. A provider that caches
|
|
13
|
+
* sessions may answer a GET from the cache, and must ask its store for any other method: that's what a mutation runs on.
|
|
14
|
+
*/
|
|
12
15
|
session(request: Request): Promise<{ user: U | null; session: S | null }>
|
|
13
16
|
/** Where `handler` is mounted (default `/api/auth`). */
|
|
14
|
-
basePath?: string
|
|
17
|
+
basePath?: string | undefined
|
|
15
18
|
$weft?: {
|
|
16
19
|
/** On the first request, never in a build: throw for a server that can't run safely. */
|
|
17
20
|
check(): void
|
|
@@ -24,7 +27,10 @@ export function defineAuth<U, S>(provider: AuthProvider<U, S>): AuthProvider<U,
|
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
/** `{ user, session }` for `ctx` (`src/context.server.ts`), from any provider. */
|
|
27
|
-
export const session = <A extends { session(request: Request): unknown }>(
|
|
30
|
+
export const session = <A extends { session(request: Request): unknown }>(
|
|
31
|
+
auth: A,
|
|
32
|
+
request: Request,
|
|
33
|
+
): ReturnType<A['session']> => auth.session(request) as ReturnType<A['session']>
|
|
28
34
|
|
|
29
35
|
const changed = new WeakSet<Headers>()
|
|
30
36
|
|
|
@@ -35,13 +41,22 @@ export const sessionChanged = (headers: Headers) => changed.has(headers)
|
|
|
35
41
|
* Finish a provider's Response (Better Auth's with `asResponse: true`): its cookies go on this response, so a redirect
|
|
36
42
|
* after it is a full load, and a failure rejects with the provider's message (`e.message` on the client).
|
|
37
43
|
*/
|
|
38
|
-
export async function forward<T = unknown>(
|
|
44
|
+
export async function forward<T = unknown>(
|
|
45
|
+
ctx: Context,
|
|
46
|
+
call: Response | Promise<Response>,
|
|
47
|
+
): Promise<T> {
|
|
39
48
|
const res = await call
|
|
40
49
|
for (const c of res.headers.getSetCookie()) {
|
|
41
50
|
ctx.response.headers.append('set-cookie', c)
|
|
42
51
|
changed.add(ctx.response.headers)
|
|
43
52
|
}
|
|
44
|
-
const body = await res.json().catch(() => null) as
|
|
45
|
-
|
|
53
|
+
const body = (await res.json().catch(() => null)) as
|
|
54
|
+
| (T & { code?: string; message?: string })
|
|
55
|
+
| null
|
|
56
|
+
if (!res.ok)
|
|
57
|
+
throw new FnError(
|
|
58
|
+
{ ...(body?.code && { code: body.code }), message: body?.message ?? 'Authentication failed' },
|
|
59
|
+
res.status,
|
|
60
|
+
)
|
|
46
61
|
return body as T
|
|
47
62
|
}
|
package/src/boot.ts
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
// less data gets it on interaction only. `#weft-route` and the islands' comments are read by `start()` once it's here.
|
|
4
4
|
|
|
5
5
|
/** What the inline script left on `window`: the queue, and how to play or wake it. */
|
|
6
|
-
export interface Held {
|
|
6
|
+
export interface Held {
|
|
7
|
+
q: unknown[]
|
|
8
|
+
p: (held: unknown) => void
|
|
9
|
+
w?: (k: Element, lead?: 1) => void
|
|
10
|
+
r?: 1
|
|
11
|
+
n?: unknown
|
|
12
|
+
l?: Element
|
|
13
|
+
d?: unknown
|
|
14
|
+
}
|
|
7
15
|
|
|
8
16
|
export function boot(full: () => Promise<unknown>) {
|
|
9
17
|
const q = (window as Window & { __wq?: Held }).__wq
|
|
@@ -26,13 +34,16 @@ export function boot(full: () => Promise<unknown>) {
|
|
|
26
34
|
if (q.d) return
|
|
27
35
|
const first = new WeakSet<Element>()
|
|
28
36
|
const needs = new WeakSet<Element>()
|
|
29
|
-
io = new IntersectionObserver(
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
io = new IntersectionObserver(
|
|
38
|
+
(list) => {
|
|
39
|
+
for (const e of list) {
|
|
40
|
+
const was = first.has(e.target)
|
|
41
|
+
first.add(e.target)
|
|
42
|
+
if (e.isIntersecting && (was || needs.has(e.target))) return go()
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{ rootMargin: '600px' },
|
|
46
|
+
)
|
|
36
47
|
const w = document.createTreeWalker(document.body, 128)
|
|
37
48
|
while (w.nextNode()) {
|
|
38
49
|
const c = w.currentNode as Comment
|
package/src/bus/postgres.ts
CHANGED
|
@@ -16,11 +16,23 @@ const CHANNEL = 'weft_bus'
|
|
|
16
16
|
const WHOLE = 7_900
|
|
17
17
|
const CHUNK = 7_800
|
|
18
18
|
|
|
19
|
+
function delivery(listeners: Map<string, Set<(message: string) => void>>) {
|
|
20
|
+
return (text: string) => {
|
|
21
|
+
const at = text.indexOf('\n')
|
|
22
|
+
const list = listeners.get(text.slice(0, at))
|
|
23
|
+
if (list) for (const on of list) on(text.slice(at + 1))
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
/** A bus on Postgres NOTIFY, for several instances with no broker beside the database. */
|
|
20
28
|
export function postgresBus({ url }: PostgresBusOptions): Bus {
|
|
21
29
|
if (!url && process.env.WEFT_BUILD) url = 'postgres://localhost/build' // a build loads this module and never connects
|
|
22
30
|
if (!/^postgres(ql)?:\/\//.test(url ?? ''))
|
|
23
|
-
throw new StartError(
|
|
31
|
+
throw new StartError(
|
|
32
|
+
'Q013',
|
|
33
|
+
`\`postgresBus\` needs a \`postgres://\` URL, not \`${(url ?? '').replace(/\/\/.*@/, '//…@')}\``,
|
|
34
|
+
'set `DATABASE_URL`, or leave `bus` out for the memory bus on one instance',
|
|
35
|
+
)
|
|
24
36
|
let sql: ReturnType<typeof postgres> | undefined
|
|
25
37
|
const client = () => (sql ??= postgres(url, { max: 2, idle_timeout: 20, onnotice: () => {} }))
|
|
26
38
|
const listeners = new Map<string, Set<(message: string) => void>>()
|
|
@@ -29,15 +41,12 @@ export function postgresBus({ url }: PostgresBusOptions): Bus {
|
|
|
29
41
|
let listening: Promise<unknown> | undefined
|
|
30
42
|
let connected = false
|
|
31
43
|
|
|
32
|
-
const deliver = (
|
|
33
|
-
const at = text.indexOf('\n')
|
|
34
|
-
const list = listeners.get(text.slice(0, at))
|
|
35
|
-
if (list) for (const on of list) on(text.slice(at + 1))
|
|
36
|
-
}
|
|
44
|
+
const deliver = delivery(listeners)
|
|
37
45
|
const received = (payload: string) => {
|
|
38
46
|
if (payload[0] === '=') return deliver(payload.slice(1))
|
|
39
47
|
const [id, i, n, body] = payload.slice(1).split(':')
|
|
40
|
-
if (id === undefined || i === undefined || n === undefined || body === undefined)
|
|
48
|
+
if (id === undefined || i === undefined || n === undefined || body === undefined)
|
|
49
|
+
return undefined
|
|
41
50
|
const got = parts.get(id) ?? []
|
|
42
51
|
got[+i] = body
|
|
43
52
|
parts.set(id, got)
|
|
@@ -46,16 +55,19 @@ export function postgresBus({ url }: PostgresBusOptions): Bus {
|
|
|
46
55
|
deliver(Buffer.from(got.join(''), 'base64').toString())
|
|
47
56
|
}
|
|
48
57
|
/** `onlisten` runs on every (re)connect: after the first, whatever was published meanwhile is gone. */
|
|
49
|
-
const listen = () =>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
const listen = () =>
|
|
59
|
+
(listening ??= client()
|
|
60
|
+
.listen(CHANNEL, received, () => {
|
|
61
|
+
if (connected) {
|
|
62
|
+
parts.clear()
|
|
63
|
+
for (const gap of gaps) gap()
|
|
64
|
+
}
|
|
65
|
+
connected = true
|
|
66
|
+
})
|
|
67
|
+
.catch((e) => {
|
|
68
|
+
listening = undefined // the next subscribe tries again
|
|
69
|
+
throw e
|
|
70
|
+
}))
|
|
59
71
|
|
|
60
72
|
return {
|
|
61
73
|
name: 'postgres',
|
|
@@ -65,8 +77,9 @@ export function postgresBus({ url }: PostgresBusOptions): Bus {
|
|
|
65
77
|
const b64 = Buffer.from(text).toString('base64')
|
|
66
78
|
const id = crypto.randomUUID()
|
|
67
79
|
const n = Math.ceil(b64.length / CHUNK)
|
|
68
|
-
await client().begin(async tx => {
|
|
69
|
-
for (let i = 0; i < n; i++)
|
|
80
|
+
await client().begin(async (tx) => {
|
|
81
|
+
for (let i = 0; i < n; i++)
|
|
82
|
+
await tx`select pg_notify(${CHANNEL}, ${`+${id}:${i}:${n}:${b64.slice(i * CHUNK, (i + 1) * CHUNK)}`})`
|
|
70
83
|
})
|
|
71
84
|
},
|
|
72
85
|
async subscribe(channel, on, gap) {
|
package/src/bus.ts
CHANGED
|
@@ -19,9 +19,9 @@ export function slotBus(plugin: string, load: () => Promise<Bus>): Bus {
|
|
|
19
19
|
const get = () => (bus ??= load())
|
|
20
20
|
return {
|
|
21
21
|
name: plugin.replace(/^.*realtime-/, ''),
|
|
22
|
-
publish: (channel, message) => get().then(b => b.publish(channel, message)),
|
|
23
|
-
subscribe: (channel, on, gap) => get().then(b => b.subscribe(channel, on, gap)),
|
|
24
|
-
close: () => (bus ? bus.then(b => b.close?.()) : Promise.resolve()),
|
|
22
|
+
publish: (channel, message) => get().then((b) => b.publish(channel, message)),
|
|
23
|
+
subscribe: (channel, on, gap) => get().then((b) => b.subscribe(channel, on, gap)),
|
|
24
|
+
close: () => (bus ? bus.then((b) => b.close?.()) : Promise.resolve()),
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -32,7 +32,10 @@ export function memoryBus(): Bus {
|
|
|
32
32
|
name: 'memory',
|
|
33
33
|
publish(channel, message) {
|
|
34
34
|
const list = channels.get(channel)
|
|
35
|
-
if (list)
|
|
35
|
+
if (list)
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
for (const on of list) on(message)
|
|
38
|
+
})
|
|
36
39
|
return Promise.resolve()
|
|
37
40
|
},
|
|
38
41
|
subscribe(channel, on) {
|
|
@@ -12,4 +12,37 @@
|
|
|
12
12
|
const CODE = `(n=>{var d=document,c=navigator.connection,q=window.__wq={q:[],n:n,w(){},d:matchMedia("(prefers-reduced-data: reduce)").matches||!!(c&&c.saveData),p(e){var t=e.target,r=q.r,s=e.submitter;if(t.isConnected){q.r=1;try{e.type=="submit"?t.requestSubmit(s&&s.isConnected?s:void 0):e.type=="reset"?t.reset():t.dispatchEvent(new e.constructor(e.type,e))}finally{q.r=r}}}},o=t=>t.closest&&t.closest("[wk]"),h=e=>{var k=!q.r&&o(e.target);if(k&&(!k.$k||q.q.length)&&!((e.metaKey||e.ctrlKey||e.shiftKey)&&e.type=="click"&&e.target.closest("a"))){q.q.push(e);/^(click|submit|reset)$/.test(e.type)&&e.preventDefault();q.w(k)}},l=e=>{var k=o(e.target);k&&!k.$k&&!q.d&&q.w(k,1)};"click dblclick pointerdown pointerup mousedown mouseup keydown keyup input change submit reset focus blur focusin focusout".split(" ").forEach(t=>d.addEventListener(t,h,!0));"pointerover touchstart".split(" ").forEach(t=>d.addEventListener(t,l,{capture:!0,passive:!0}))})`
|
|
13
13
|
|
|
14
14
|
/** The script's text. `now`: the page has work that can't wait, so its code loads straight away. */
|
|
15
|
-
export const capture = now => `${CODE}(${now ? 1 : 0})`
|
|
15
|
+
export const capture = (now: boolean) => `${CODE}(${now ? 1 : 0})`
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A page that runs no client until it's needed follows its links with full loads, so the browser prefetches the one the
|
|
19
|
+
* pointer rests on (`moderate`). Not a prerender, which would run the next page's code unseen. An island's links go
|
|
20
|
+
* through the router once its code comes, so they're left out, as is `/_weft/` and anything under `data-preload="off"`;
|
|
21
|
+
* `start()` takes the rules down when the router takes over, so nothing is fetched twice.
|
|
22
|
+
*/
|
|
23
|
+
const RULES = JSON.stringify({
|
|
24
|
+
prefetch: [
|
|
25
|
+
{
|
|
26
|
+
source: 'document',
|
|
27
|
+
where: {
|
|
28
|
+
and: [
|
|
29
|
+
{ href_matches: '/*' },
|
|
30
|
+
{ not: { href_matches: '/_weft/*' } },
|
|
31
|
+
{
|
|
32
|
+
not: {
|
|
33
|
+
selector_matches:
|
|
34
|
+
'[wk] a, a[wk], [data-preload="off"] a, a[data-preload="off"], a[download]',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
eagerness: 'moderate',
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/** The rules, for a page whose code waits (`now` false, nothing streaming). */
|
|
45
|
+
export const speculate = (shape: { now: boolean; streaming?: boolean }) =>
|
|
46
|
+
shape.now || shape.streaming
|
|
47
|
+
? ''
|
|
48
|
+
: `<script type="speculationrules" id="weft-speculate">${RULES}</script>\n`
|
package/src/changes-sql.d.mts
CHANGED
|
@@ -9,6 +9,12 @@ export const LOG_ROWS: number
|
|
|
9
9
|
/** Between statements of generated SQL, as Drizzle splits them. */
|
|
10
10
|
export const BREAK: string
|
|
11
11
|
/** The trigger statements for these tables, one by one. */
|
|
12
|
-
export function changeStatements(
|
|
12
|
+
export function changeStatements(
|
|
13
|
+
dialect: string,
|
|
14
|
+
tables: readonly { name: string; pk?: string[] }[],
|
|
15
|
+
): string[]
|
|
13
16
|
/** The same statements as one migration. */
|
|
14
|
-
export function changeSql(
|
|
17
|
+
export function changeSql(
|
|
18
|
+
dialect: string,
|
|
19
|
+
tables: readonly { name: string; pk?: string[] }[],
|
|
20
|
+
): string
|
package/src/changes-sql.mjs
CHANGED
|
@@ -14,10 +14,10 @@ export const MAX_IDS = 32
|
|
|
14
14
|
export const LOG_ROWS = 4096
|
|
15
15
|
export const BREAK = '\n--> statement-breakpoint\n'
|
|
16
16
|
|
|
17
|
-
const pgName = n => `"${n.replaceAll('"', '""')}"`
|
|
18
|
-
const myName = n => `\`${n.replaceAll('`', '``')}\``
|
|
19
|
-
const literal = s => `'${s.replaceAll("'", "''")}'`
|
|
20
|
-
const safe = t => t.replace(/\W/g, '_')
|
|
17
|
+
const pgName = (n) => `"${n.replaceAll('"', '""')}"`
|
|
18
|
+
const myName = (n) => `\`${n.replaceAll('`', '``')}\``
|
|
19
|
+
const literal = (s) => `'${s.replaceAll("'", "''")}'`
|
|
20
|
+
const safe = (t) => t.replace(/\W/g, '_')
|
|
21
21
|
|
|
22
22
|
function postgres(tables) {
|
|
23
23
|
const fn = `CREATE OR REPLACE FUNCTION weft_changes_notify() RETURNS trigger AS $weft$
|
|
@@ -48,7 +48,12 @@ $weft$ LANGUAGE plpgsql`
|
|
|
48
48
|
`DROP TRIGGER IF EXISTS weft_changes_${suffix} ON ${on}`,
|
|
49
49
|
`CREATE TRIGGER weft_changes_${suffix} AFTER ${event} ON ${on} ${rows ? `REFERENCING ${rows} TABLE AS weft_rows ` : ''}FOR EACH STATEMENT EXECUTE FUNCTION weft_changes_notify(${args})`,
|
|
50
50
|
]
|
|
51
|
-
return [
|
|
51
|
+
return [
|
|
52
|
+
...trigger('ins', 'INSERT', 'NEW'),
|
|
53
|
+
...trigger('upd', 'UPDATE', 'NEW'),
|
|
54
|
+
...trigger('del', 'DELETE', 'OLD'),
|
|
55
|
+
...trigger('trn', 'TRUNCATE'),
|
|
56
|
+
]
|
|
52
57
|
})
|
|
53
58
|
return [fn, ...list]
|
|
54
59
|
}
|
|
@@ -56,7 +61,8 @@ $weft$ LANGUAGE plpgsql`
|
|
|
56
61
|
function sqlite(tables) {
|
|
57
62
|
const log = `CREATE TABLE IF NOT EXISTS ${LOG} (seq INTEGER PRIMARY KEY AUTOINCREMENT, tbl TEXT NOT NULL, pk TEXT)`
|
|
58
63
|
const list = tables.flatMap(({ name, pk }) => {
|
|
59
|
-
const id =
|
|
64
|
+
const id = (row) =>
|
|
65
|
+
pk.length ? pk.map((c) => `${row}.${pgName(c)}`).join(` || ':' || `) : 'NULL'
|
|
60
66
|
const trigger = (suffix, event, row) => [
|
|
61
67
|
`DROP TRIGGER IF EXISTS weft_changes_${safe(name)}_${suffix}`,
|
|
62
68
|
`CREATE TRIGGER weft_changes_${safe(name)}_${suffix} AFTER ${event} ON ${pgName(name)} BEGIN
|
|
@@ -64,7 +70,11 @@ function sqlite(tables) {
|
|
|
64
70
|
DELETE FROM ${LOG} WHERE seq <= last_insert_rowid() - ${LOG_ROWS};
|
|
65
71
|
END`,
|
|
66
72
|
]
|
|
67
|
-
return [
|
|
73
|
+
return [
|
|
74
|
+
...trigger('ins', 'INSERT', 'NEW'),
|
|
75
|
+
...trigger('upd', 'UPDATE', 'NEW'),
|
|
76
|
+
...trigger('del', 'DELETE', 'OLD'),
|
|
77
|
+
]
|
|
68
78
|
})
|
|
69
79
|
return [log, ...list]
|
|
70
80
|
}
|
|
@@ -72,12 +82,17 @@ END`,
|
|
|
72
82
|
function mysql(tables) {
|
|
73
83
|
const log = `CREATE TABLE IF NOT EXISTS ${LOG} (seq BIGINT AUTO_INCREMENT PRIMARY KEY, tbl VARCHAR(64) NOT NULL, pk VARCHAR(255), at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY at_idx (at))`
|
|
74
84
|
const list = tables.flatMap(({ name, pk }) => {
|
|
75
|
-
const id =
|
|
85
|
+
const id = (row) =>
|
|
86
|
+
pk.length ? `CONCAT_WS(':', ${pk.map((c) => `${row}.${myName(c)}`).join(', ')})` : 'NULL'
|
|
76
87
|
const trigger = (suffix, event, row) => [
|
|
77
88
|
`DROP TRIGGER IF EXISTS weft_changes_${safe(name)}_${suffix}`,
|
|
78
89
|
`CREATE TRIGGER weft_changes_${safe(name)}_${suffix} AFTER ${event} ON ${myName(name)} FOR EACH ROW INSERT INTO ${LOG} (tbl, pk) VALUES (${literal(name)}, ${id(row)})`,
|
|
79
90
|
]
|
|
80
|
-
return [
|
|
91
|
+
return [
|
|
92
|
+
...trigger('ins', 'INSERT', 'NEW'),
|
|
93
|
+
...trigger('upd', 'UPDATE', 'NEW'),
|
|
94
|
+
...trigger('del', 'DELETE', 'OLD'),
|
|
95
|
+
]
|
|
81
96
|
})
|
|
82
97
|
return [log, ...list]
|
|
83
98
|
}
|
|
@@ -96,4 +111,5 @@ export function changeStatements(dialect, tables) {
|
|
|
96
111
|
}
|
|
97
112
|
|
|
98
113
|
/** A migration file, in drizzle-kit's format. */
|
|
99
|
-
export const changeSql = (dialect, tables) =>
|
|
114
|
+
export const changeSql = (dialect, tables) =>
|
|
115
|
+
`-- weft: report changes to ${tables.map((t) => t.name).join(', ')} (\`weft db live\`). Ids only, never a row.\n${changeStatements(dialect, tables).join(`;${BREAK}`)};\n`
|