@weft-js/start 0.1.1 → 0.1.2
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 +36 -3
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -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/list-drizzle.ts
CHANGED
|
@@ -1,21 +1,67 @@
|
|
|
1
1
|
// The `db.adapter@1` default (research 13 §2.2): a list source over a Drizzle table. `columns` keeps its meaning here,
|
|
2
2
|
// so a table's other columns stay out of what's selected, returned or written.
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
and,
|
|
6
|
+
asc,
|
|
7
|
+
count,
|
|
8
|
+
desc,
|
|
9
|
+
eq,
|
|
10
|
+
gt,
|
|
11
|
+
inArray,
|
|
12
|
+
isNull,
|
|
13
|
+
lt,
|
|
14
|
+
or,
|
|
15
|
+
sql,
|
|
16
|
+
type Column,
|
|
17
|
+
type SQL,
|
|
18
|
+
} from 'drizzle-orm'
|
|
19
|
+
import {
|
|
20
|
+
likeOf,
|
|
21
|
+
pageOf,
|
|
22
|
+
plan,
|
|
23
|
+
rowOf,
|
|
24
|
+
storedId,
|
|
25
|
+
written,
|
|
26
|
+
type DbAdapter,
|
|
27
|
+
type ListPage,
|
|
28
|
+
type ListParams,
|
|
29
|
+
type ListSource,
|
|
30
|
+
type SourceSpec,
|
|
31
|
+
} from './list'
|
|
6
32
|
|
|
7
33
|
/** The Drizzle client and table a list source runs on. */
|
|
8
|
-
export interface DrizzleTarget {
|
|
34
|
+
export interface DrizzleTarget {
|
|
35
|
+
db: object
|
|
36
|
+
table: object
|
|
37
|
+
}
|
|
9
38
|
|
|
10
39
|
/** What every dialect's query builders share by name, though no type says so. */
|
|
11
|
-
type Chain = PromiseLike<object[]> & {
|
|
12
|
-
|
|
40
|
+
type Chain = PromiseLike<object[]> & {
|
|
41
|
+
from(t: object): Chain
|
|
42
|
+
where(w?: SQL): Chain
|
|
43
|
+
orderBy(...o: SQL[]): Chain
|
|
44
|
+
limit(n: number): Chain
|
|
45
|
+
offset(n: number): Chain
|
|
46
|
+
values(v: object): Chain
|
|
47
|
+
set(v: object): Chain
|
|
48
|
+
returning(s?: object): Chain
|
|
49
|
+
}
|
|
50
|
+
interface Db {
|
|
51
|
+
select(s: object): Chain
|
|
52
|
+
insert(t: object): Chain
|
|
53
|
+
update(t: object): Chain
|
|
54
|
+
delete(t: object): Chain
|
|
55
|
+
}
|
|
13
56
|
|
|
14
57
|
/** A `ListSource` over a Drizzle table. */
|
|
15
|
-
export function drizzleSource<Row = Record<string, unknown>>(
|
|
58
|
+
export function drizzleSource<Row = Record<string, unknown>>(
|
|
59
|
+
{ db: handle, table }: DrizzleTarget,
|
|
60
|
+
spec: SourceSpec,
|
|
61
|
+
): ListSource<Row> {
|
|
16
62
|
const db = handle as Db
|
|
17
63
|
const col = (name: string) => (table as Record<string, Column>)[name]!
|
|
18
|
-
const selection = Object.fromEntries(Object.values(spec.columns).map(c => [c, col(c)]))
|
|
64
|
+
const selection = Object.fromEntries(Object.values(spec.columns).map((c) => [c, col(c)]))
|
|
19
65
|
const public_ = rowOf<Row>(spec)
|
|
20
66
|
const id = col(storedId(spec))
|
|
21
67
|
const reading = (rows: object[]) => rows.map(public_)
|
|
@@ -23,26 +69,66 @@ export function drizzleSource<Row = Record<string, unknown>>({ db: handle, table
|
|
|
23
69
|
return {
|
|
24
70
|
async list(p: ListParams): Promise<ListPage<Row>> {
|
|
25
71
|
const q = plan(spec, p)
|
|
26
|
-
const filters: (SQL | undefined)[] = q.where.map(w => {
|
|
27
|
-
const some = w.values.filter(v => v !== null)
|
|
28
|
-
const hit =
|
|
72
|
+
const filters: (SQL | undefined)[] = q.where.map((w) => {
|
|
73
|
+
const some = w.values.filter((v) => v !== null)
|
|
74
|
+
const hit =
|
|
75
|
+
some.length === 1
|
|
76
|
+
? eq(col(w.column), some[0])
|
|
77
|
+
: some.length
|
|
78
|
+
? inArray(col(w.column), some)
|
|
79
|
+
: undefined
|
|
29
80
|
return w.values.includes(null) ? or(hit, isNull(col(w.column))) : hit
|
|
30
81
|
})
|
|
31
|
-
if (q.search)
|
|
82
|
+
if (q.search)
|
|
83
|
+
filters.push(
|
|
84
|
+
or(
|
|
85
|
+
...q.search.columns.map(
|
|
86
|
+
(c) => sql`lower(${col(c)}) like ${likeOf(q.search!.text)} escape '!'`,
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
)
|
|
32
90
|
const where = and(...filters)
|
|
33
91
|
const by = q.sort?.dir === 'desc' ? desc : asc
|
|
34
92
|
const past = q.sort?.dir === 'desc' ? lt : gt
|
|
35
|
-
const keyset = p.after
|
|
93
|
+
const keyset = p.after
|
|
94
|
+
? q.sort
|
|
95
|
+
? or(
|
|
96
|
+
past(col(q.sort.column), p.after[0]),
|
|
97
|
+
and(eq(col(q.sort.column), p.after[0]), past(id, p.after[1])),
|
|
98
|
+
)
|
|
99
|
+
: past(id, p.after[1])
|
|
100
|
+
: undefined
|
|
36
101
|
const order = q.sort ? [by(col(q.sort.column)), by(id)] : [asc(id)]
|
|
37
102
|
const cursor = p.mode === 'cursor'
|
|
38
|
-
const rows = await db
|
|
39
|
-
|
|
103
|
+
const rows = await db
|
|
104
|
+
.select(selection)
|
|
105
|
+
.from(table)
|
|
106
|
+
.where(and(where, keyset))
|
|
107
|
+
.orderBy(...order)
|
|
108
|
+
.limit(cursor ? p.limit + 1 : p.limit)
|
|
109
|
+
.offset(p.offset)
|
|
110
|
+
const total = cursor
|
|
111
|
+
? undefined
|
|
112
|
+
: ((await db.select({ n: count() }).from(table).where(where)) as { n: number }[])[0]!.n
|
|
40
113
|
return pageOf(reading(rows), p, { sort: q.sort?.field, id: q.id.field }, total)
|
|
41
114
|
},
|
|
42
|
-
get: id_ => one(db.select(selection).from(table).where(eq(id, id_)).limit(1)),
|
|
43
|
-
create: async data =>
|
|
44
|
-
|
|
45
|
-
|
|
115
|
+
get: (id_) => one(db.select(selection).from(table).where(eq(id, id_)).limit(1)),
|
|
116
|
+
create: async (data) =>
|
|
117
|
+
(await one(
|
|
118
|
+
db
|
|
119
|
+
.insert(table)
|
|
120
|
+
.values(written(spec, data, true))
|
|
121
|
+
.returning(selection),
|
|
122
|
+
))!,
|
|
123
|
+
update: (id_, patch) => {
|
|
124
|
+
const row = written(spec, patch)
|
|
125
|
+
return one(
|
|
126
|
+
Object.keys(row).length
|
|
127
|
+
? db.update(table).set(row).where(eq(id, id_)).returning(selection)
|
|
128
|
+
: db.select(selection).from(table).where(eq(id, id_)).limit(1),
|
|
129
|
+
)
|
|
130
|
+
},
|
|
131
|
+
remove: async (id_) => (await db.delete(table).where(eq(id, id_)).returning({ id })).length > 0,
|
|
46
132
|
}
|
|
47
133
|
}
|
|
48
134
|
|
package/src/list.ts
CHANGED
|
@@ -17,7 +17,10 @@ export type Scalar = string | number | boolean | null
|
|
|
17
17
|
/** Which way a sort runs. */
|
|
18
18
|
export type Dir = 'asc' | 'desc'
|
|
19
19
|
/** One field and a direction. */
|
|
20
|
-
export interface Sort {
|
|
20
|
+
export interface Sort {
|
|
21
|
+
field: string
|
|
22
|
+
dir: Dir
|
|
23
|
+
}
|
|
21
24
|
|
|
22
25
|
/** A checked request, as a source takes it. */
|
|
23
26
|
export interface ListParams {
|
|
@@ -33,8 +36,13 @@ export interface ListParams {
|
|
|
33
36
|
filter: Record<string, Scalar | Scalar[]>
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
/** The envelope
|
|
37
|
-
export interface ListPage<T> {
|
|
39
|
+
/** The envelope Warp's `Table` reads. `next` and `prev` go back in as `cursor` or `page`; absent means no such page. */
|
|
40
|
+
export interface ListPage<T> {
|
|
41
|
+
items: T[]
|
|
42
|
+
total?: number
|
|
43
|
+
next?: string | number
|
|
44
|
+
prev?: string | number
|
|
45
|
+
}
|
|
38
46
|
|
|
39
47
|
/** What a list lets a client ask for. */
|
|
40
48
|
export interface ListSpec {
|
|
@@ -57,28 +65,42 @@ export interface ListRequest {
|
|
|
57
65
|
filter?: Record<string, Scalar | Scalar[]>
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
const SEARCH_MAX = 200,
|
|
68
|
+
const SEARCH_MAX = 200,
|
|
69
|
+
IN_MAX = 100,
|
|
70
|
+
CURSOR_MAX = 1024
|
|
61
71
|
|
|
62
|
-
const scalar = (v: unknown): v is Scalar =>
|
|
72
|
+
const scalar = (v: unknown): v is Scalar =>
|
|
73
|
+
v === null ||
|
|
74
|
+
typeof v === 'boolean' ||
|
|
75
|
+
typeof v === 'string' ||
|
|
76
|
+
(typeof v === 'number' && Number.isFinite(v))
|
|
63
77
|
|
|
64
78
|
const pack = (v: unknown) => (v instanceof Date ? { $d: v.toISOString() } : v)
|
|
65
|
-
const stamped = (v: unknown): v is { $d: string } =>
|
|
79
|
+
const stamped = (v: unknown): v is { $d: string } =>
|
|
80
|
+
typeof (v as { $d?: unknown } | null)?.$d === 'string'
|
|
66
81
|
const unpack = (v: unknown) => (stamped(v) ? new Date(v.$d) : v)
|
|
67
82
|
|
|
68
83
|
/** Opaque to a client, and read with the same care as any other input. */
|
|
69
84
|
export function encodeCursor(value: unknown, id: unknown): string {
|
|
70
85
|
const bytes = new TextEncoder().encode(JSON.stringify([pack(value), pack(id)]))
|
|
71
|
-
return btoa(String.fromCharCode(...bytes))
|
|
86
|
+
return btoa(String.fromCharCode(...bytes))
|
|
87
|
+
.replaceAll('+', '-')
|
|
88
|
+
.replaceAll('/', '_')
|
|
89
|
+
.replace(/=+$/, '')
|
|
72
90
|
}
|
|
73
91
|
|
|
74
92
|
/** The other way from `encodeCursor`: null for anything that isn't a cursor of ours. */
|
|
75
93
|
export function decodeCursor(text: string): [unknown, unknown] | null {
|
|
76
94
|
if (text.length > CURSOR_MAX) return null
|
|
77
95
|
try {
|
|
78
|
-
const bytes = Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), c =>
|
|
96
|
+
const bytes = Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), (c) =>
|
|
97
|
+
c.charCodeAt(0),
|
|
98
|
+
)
|
|
79
99
|
const list: unknown = JSON.parse(new TextDecoder().decode(bytes))
|
|
80
100
|
const fits = (v: unknown) => scalar(v) || (stamped(v) && !Number.isNaN(Date.parse(v.$d)))
|
|
81
|
-
return Array.isArray(list) && list.length === 2 && list.every(fits)
|
|
101
|
+
return Array.isArray(list) && list.length === 2 && list.every(fits)
|
|
102
|
+
? [unpack(list[0]), unpack(list[1])]
|
|
103
|
+
: null
|
|
82
104
|
} catch {
|
|
83
105
|
return null
|
|
84
106
|
}
|
|
@@ -86,38 +108,68 @@ export function decodeCursor(text: string): [unknown, unknown] | null {
|
|
|
86
108
|
|
|
87
109
|
/** A `query`'s `input`. Takes a `ListRequest` from the client, and hands `run` the `ListParams` a source takes. */
|
|
88
110
|
export function listInput(spec: ListSpec): StandardSchemaV1<ListRequest | undefined, ListParams> {
|
|
89
|
-
const fallback = spec.pageSize?.default ?? 25,
|
|
90
|
-
|
|
111
|
+
const fallback = spec.pageSize?.default ?? 25,
|
|
112
|
+
max = spec.pageSize?.max ?? 100
|
|
113
|
+
const sorts = new Set(spec.sort),
|
|
114
|
+
filters = new Set(spec.filter)
|
|
91
115
|
const validate = (input: unknown) => {
|
|
92
116
|
const issues: { message: string; path?: string[] }[] = []
|
|
93
117
|
const no = (message: string, ...path: string[]) => void issues.push({ message, path })
|
|
94
118
|
const r = (input ?? {}) as ListRequest
|
|
95
|
-
if (typeof r !== 'object' || Array.isArray(r))
|
|
119
|
+
if (typeof r !== 'object' || Array.isArray(r))
|
|
120
|
+
return { issues: [{ message: 'a list request is an object' }] }
|
|
96
121
|
const mode = r.mode ?? (r.cursor !== undefined ? 'cursor' : 'page')
|
|
97
122
|
if (mode !== 'page' && mode !== 'cursor') no('`mode` is `page` or `cursor`', 'mode')
|
|
98
123
|
const size = r.pageSize ?? fallback
|
|
99
|
-
if (!Number.isInteger(size) || size < 1 || size > max)
|
|
124
|
+
if (!Number.isInteger(size) || size < 1 || size > max)
|
|
125
|
+
no(`\`pageSize\` is a whole number up to ${max}`, 'pageSize')
|
|
100
126
|
const page = r.page ?? 1
|
|
101
|
-
if (!Number.isInteger(page) || page < 1 || page > 1e6)
|
|
127
|
+
if (!Number.isInteger(page) || page < 1 || page > 1e6)
|
|
128
|
+
no('`page` is a whole number from 1', 'page')
|
|
102
129
|
let after: ListParams['after'] = null
|
|
103
130
|
if (r.cursor != null) {
|
|
104
131
|
after = typeof r.cursor === 'string' ? decodeCursor(r.cursor) : null
|
|
105
132
|
if (!after) no('that cursor is not one of ours', 'cursor')
|
|
106
133
|
}
|
|
107
134
|
const sort = r.sort ?? spec.defaultSort ?? null
|
|
108
|
-
if (
|
|
109
|
-
|
|
135
|
+
if (
|
|
136
|
+
sort &&
|
|
137
|
+
!(
|
|
138
|
+
typeof sort === 'object' &&
|
|
139
|
+
sorts.has(sort.field) &&
|
|
140
|
+
(sort.dir === 'asc' || sort.dir === 'desc')
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
no('that sort is not allowed', 'sort')
|
|
144
|
+
if (r.search !== undefined && (typeof r.search !== 'string' || r.search.length > SEARCH_MAX))
|
|
145
|
+
no(`\`search\` is text up to ${SEARCH_MAX} characters`, 'search')
|
|
110
146
|
if (r.search && !spec.search?.length) no('this list has no search', 'search')
|
|
111
147
|
const filter: ListParams['filter'] = Object.create(null)
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
148
|
+
if (
|
|
149
|
+
r.filter !== undefined &&
|
|
150
|
+
(typeof r.filter !== 'object' || r.filter === null || Array.isArray(r.filter))
|
|
151
|
+
)
|
|
152
|
+
no('`filter` is an object', 'filter')
|
|
153
|
+
else
|
|
154
|
+
for (const key of Object.keys(r.filter ?? {})) {
|
|
155
|
+
const v = r.filter![key]!
|
|
156
|
+
if (!filters.has(key)) no(`\`${key}\` can't be filtered on`, 'filter', key)
|
|
157
|
+
else if (
|
|
158
|
+
Array.isArray(v) ? !(v.length && v.length <= IN_MAX && v.every(scalar)) : !scalar(v)
|
|
159
|
+
)
|
|
160
|
+
no(`\`${key}\` takes a value or a list of up to ${IN_MAX}, not an object`, 'filter', key)
|
|
161
|
+
else filter[key] = v
|
|
162
|
+
}
|
|
119
163
|
if (issues.length) return { issues }
|
|
120
|
-
const params: ListParams = {
|
|
164
|
+
const params: ListParams = {
|
|
165
|
+
mode,
|
|
166
|
+
limit: size,
|
|
167
|
+
offset: mode === 'page' ? (page - 1) * size : 0,
|
|
168
|
+
after,
|
|
169
|
+
sort,
|
|
170
|
+
filter,
|
|
171
|
+
...(r.search ? { search: r.search } : {}),
|
|
172
|
+
}
|
|
121
173
|
return { value: params }
|
|
122
174
|
}
|
|
123
175
|
return { '~standard': { version: 1, vendor: 'weft', validate } }
|
|
@@ -155,7 +207,13 @@ export interface DbAdapter<T = unknown> {
|
|
|
155
207
|
source<Row = Record<string, unknown>>(target: T, spec: SourceSpec): ListSource<Row>
|
|
156
208
|
}
|
|
157
209
|
|
|
158
|
-
const refuse = (what: string) =>
|
|
210
|
+
const refuse = (what: string) =>
|
|
211
|
+
new StartError(
|
|
212
|
+
'Q002',
|
|
213
|
+
`the list source refused ${what}`,
|
|
214
|
+
"add it to the source's `columns`, and to `listInput`",
|
|
215
|
+
{ status: 400 },
|
|
216
|
+
)
|
|
159
217
|
|
|
160
218
|
/** A request checked against the source and put in stored names. Throws on any name outside `columns`. */
|
|
161
219
|
export interface Plan {
|
|
@@ -175,11 +233,20 @@ export function storedId(spec: SourceSpec): string {
|
|
|
175
233
|
|
|
176
234
|
/** Check a request against `spec`, for a source to run. */
|
|
177
235
|
export function plan(spec: SourceSpec, p: ListParams): Plan {
|
|
178
|
-
const stored = (field: string) =>
|
|
236
|
+
const stored = (field: string) =>
|
|
237
|
+
Object.hasOwn(spec.columns, field)
|
|
238
|
+
? spec.columns[field]!
|
|
239
|
+
: (() => {
|
|
240
|
+
throw refuse(`\`${field}\``)
|
|
241
|
+
})()
|
|
179
242
|
const id = spec.id ?? 'id'
|
|
180
|
-
const where = Object.keys(p.filter).map(field => ({
|
|
181
|
-
|
|
182
|
-
|
|
243
|
+
const where = Object.keys(p.filter).map((field) => ({
|
|
244
|
+
column: stored(field),
|
|
245
|
+
values: [p.filter[field]!].flat(),
|
|
246
|
+
}))
|
|
247
|
+
if (where.some((w) => !w.values.every(scalar)))
|
|
248
|
+
throw refuse('a filter value that is not a scalar')
|
|
249
|
+
const searching = p.search ? (spec.search ?? []) : []
|
|
183
250
|
if (p.search && !searching.length) throw refuse('a search')
|
|
184
251
|
return {
|
|
185
252
|
sort: p.sort ? { column: stored(p.sort.field), dir: p.sort.dir, field: p.sort.field } : null,
|
|
@@ -192,32 +259,56 @@ export function plan(spec: SourceSpec, p: ListParams): Plan {
|
|
|
192
259
|
/** A stored row in public names, with only `columns` in it. */
|
|
193
260
|
export const rowOf = <Row = Record<string, unknown>>(spec: SourceSpec) => {
|
|
194
261
|
const pairs = Object.entries(spec.columns)
|
|
195
|
-
return (row: object) =>
|
|
262
|
+
return (row: object) =>
|
|
263
|
+
Object.fromEntries(
|
|
264
|
+
pairs.filter(([, c]) => c in row).map(([f, c]) => [f, (row as Record<string, unknown>)[c]]),
|
|
265
|
+
) as Row
|
|
196
266
|
}
|
|
197
267
|
|
|
198
268
|
/** A write in stored names. Refuses anything outside `write`, so a client can't set `role` or move an id. */
|
|
199
|
-
export function written(
|
|
269
|
+
export function written(
|
|
270
|
+
spec: SourceSpec,
|
|
271
|
+
data: Record<string, unknown>,
|
|
272
|
+
creating = false,
|
|
273
|
+
): Record<string, unknown> {
|
|
200
274
|
const id = spec.id ?? 'id'
|
|
201
|
-
const allowed = new Set(spec.write ?? Object.keys(spec.columns).filter(f => f !== id))
|
|
275
|
+
const allowed = new Set(spec.write ?? Object.keys(spec.columns).filter((f) => f !== id))
|
|
202
276
|
if (creating) allowed.add(id)
|
|
203
277
|
const out: Record<string, unknown> = {}
|
|
204
278
|
for (const field of Object.keys(data)) {
|
|
205
|
-
if (!allowed.has(field) || !Object.hasOwn(spec.columns, field))
|
|
279
|
+
if (!allowed.has(field) || !Object.hasOwn(spec.columns, field))
|
|
280
|
+
throw refuse(`a write to \`${field}\``)
|
|
206
281
|
out[spec.columns[field]!] = data[field]
|
|
207
282
|
}
|
|
208
283
|
return out
|
|
209
284
|
}
|
|
210
285
|
|
|
211
286
|
/** `rows` are in public names, with one more than `limit` in cursor mode when there is a next page. */
|
|
212
|
-
export function pageOf<R>(
|
|
287
|
+
export function pageOf<R>(
|
|
288
|
+
rows: R[],
|
|
289
|
+
p: ListParams,
|
|
290
|
+
field: { sort?: string | undefined; id: string },
|
|
291
|
+
total?: number,
|
|
292
|
+
): ListPage<R> {
|
|
213
293
|
if (p.mode === 'page') {
|
|
214
294
|
const page = p.offset / p.limit + 1
|
|
215
|
-
return {
|
|
295
|
+
return {
|
|
296
|
+
items: rows,
|
|
297
|
+
...(total === undefined ? {} : { total }),
|
|
298
|
+
...(total !== undefined && p.offset + p.limit < total ? { next: page + 1 } : {}),
|
|
299
|
+
...(page > 1 ? { prev: page - 1 } : {}),
|
|
300
|
+
}
|
|
216
301
|
}
|
|
217
302
|
const more = rows.length > p.limit
|
|
218
303
|
const items = more ? rows.slice(0, p.limit) : rows
|
|
219
304
|
const last = items.at(-1) as R & Record<string, unknown>
|
|
220
|
-
return {
|
|
305
|
+
return {
|
|
306
|
+
items,
|
|
307
|
+
...(total === undefined ? {} : { total }),
|
|
308
|
+
...(more
|
|
309
|
+
? { next: encodeCursor(field.sort ? last[field.sort] : last[field.id], last[field.id]) }
|
|
310
|
+
: {}),
|
|
311
|
+
}
|
|
221
312
|
}
|
|
222
313
|
|
|
223
314
|
/** `LIKE` with `!` as the escape: the one character that means the same on SQLite, Postgres and MySQL. */
|
package/src/listen.ts
CHANGED
|
@@ -8,17 +8,43 @@ import { StartError } from './data'
|
|
|
8
8
|
const BEAT = 10_000
|
|
9
9
|
|
|
10
10
|
/** Listens until the returned `stop`. Run one: two listeners poke twice, which costs a refetch and loses nothing. */
|
|
11
|
-
export async function listen({
|
|
12
|
-
|
|
11
|
+
export async function listen({
|
|
12
|
+
changes,
|
|
13
|
+
bus,
|
|
14
|
+
say = console.info,
|
|
15
|
+
beat = BEAT,
|
|
16
|
+
}: {
|
|
17
|
+
changes: Source
|
|
18
|
+
bus: Bus
|
|
19
|
+
say?: (line: string) => void
|
|
20
|
+
beat?: number
|
|
21
|
+
}) {
|
|
22
|
+
if (bus.name === 'memory')
|
|
23
|
+
throw new StartError(
|
|
24
|
+
'R017',
|
|
25
|
+
'`weft db listen` publishes to the bus, and the memory bus stops at this process',
|
|
26
|
+
'set `bus` in `src/realtime.server.ts`',
|
|
27
|
+
)
|
|
13
28
|
const how = await changes.probe()
|
|
14
29
|
say(`weft live: ${how.mode} (${how.via})${how.why ? `: ${how.why}` : ''}`)
|
|
15
|
-
if (how.mode !== 'feed')
|
|
30
|
+
if (how.mode !== 'feed')
|
|
31
|
+
throw new StartError(
|
|
32
|
+
'R015',
|
|
33
|
+
`this listener has no real feed${how.why ? `: ${how.why}` : ''}`,
|
|
34
|
+
how.fix,
|
|
35
|
+
)
|
|
16
36
|
const feed = createFeed(changes, {
|
|
17
|
-
emit:
|
|
18
|
-
|
|
37
|
+
emit: (p) =>
|
|
38
|
+
void bus
|
|
39
|
+
.publish('weft:poke', JSON.stringify(p))
|
|
40
|
+
.catch((e) => say(`weft live: publish failed: ${e?.message ?? e}`)),
|
|
41
|
+
status: (down) => say(down ? `weft live: down: ${down}` : 'weft live: back'),
|
|
19
42
|
})
|
|
20
43
|
await feed.start(how)
|
|
21
|
-
const beats = setInterval(
|
|
44
|
+
const beats = setInterval(
|
|
45
|
+
() => void bus.publish('weft:poke', '{"v":1,"b":1}').catch(() => {}),
|
|
46
|
+
beat,
|
|
47
|
+
)
|
|
22
48
|
await bus.publish('weft:poke', '{"v":1,"b":1}')
|
|
23
49
|
return async () => {
|
|
24
50
|
clearInterval(beats)
|
package/src/live/api.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LiveApi } from '../data'
|
|
2
|
+
|
|
3
|
+
// `data.ts` hands these over (`use`). A static import would close a cycle with its `import('./live')`, and the bundler
|
|
4
|
+
// would then keep this chunk, the hub and the split they force in apps that never read `.live`.
|
|
5
|
+
export let cache: LiveApi['cache'],
|
|
6
|
+
currentScope: LiveApi['currentScope'],
|
|
7
|
+
fns: LiveApi['fns'],
|
|
8
|
+
FnError: LiveApi['FnError'],
|
|
9
|
+
keyOf: LiveApi['keyOf'],
|
|
10
|
+
net: LiveApi['net'],
|
|
11
|
+
rt: LiveApi['rt'],
|
|
12
|
+
runtime: LiveApi['runtime'],
|
|
13
|
+
skip: LiveApi['skip'],
|
|
14
|
+
StartError: LiveApi['StartError'],
|
|
15
|
+
track: LiveApi['track']
|
|
16
|
+
export const use = (api: LiveApi) =>
|
|
17
|
+
void ({ cache, currentScope, fns, FnError, keyOf, net, rt, runtime, skip, StartError, track } =
|
|
18
|
+
api)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { DEV } from '@weft-js/core'
|
|
2
|
+
import { cache, FnError } from './api'
|
|
3
|
+
import type { Sub } from './sub-types'
|
|
4
|
+
|
|
5
|
+
export type Row = { 0: number; 1: never; 2: unknown }
|
|
6
|
+
|
|
7
|
+
export function ack(d: Row, byN: Map<number, Sub>, seeded: () => boolean) {
|
|
8
|
+
const s = byN.get(d[0])
|
|
9
|
+
if (!s) return
|
|
10
|
+
s.tags = d[1]
|
|
11
|
+
s.error.value = undefined
|
|
12
|
+
if (DEV && !s.tags.length)
|
|
13
|
+
console.warn(
|
|
14
|
+
`weft: W053 \`${s.fn.$id}\` is read with \`.live()\` but declares no \`tags\`: no mutation can ever poke it.\n fix: add \`tags\` to the query`,
|
|
15
|
+
)
|
|
16
|
+
// Data older than the subscription may have missed a write. A page's first connect is covered by its cursor.
|
|
17
|
+
if (!s.acked && !seeded() && s.tags.length) void cache.invalidate(...s.tags)
|
|
18
|
+
s.acked = true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function update(event: string, d: Row, byN: Map<number, Sub>) {
|
|
22
|
+
if (event === 'm') {
|
|
23
|
+
const s = byN.get(d[0])
|
|
24
|
+
if (!s) return
|
|
25
|
+
if (d[1] !== null) s.cursor = d[1]
|
|
26
|
+
s.error.value = undefined
|
|
27
|
+
for (const v of s.views.values())
|
|
28
|
+
v.box.value = v.fold ? v.fold(v.box.value as never, d[2] as never) : d[2]
|
|
29
|
+
} else if (event === 'r') {
|
|
30
|
+
const s = byN.get(d[0])
|
|
31
|
+
if (!s) return
|
|
32
|
+
s.cursor = undefined
|
|
33
|
+
for (const v of s.views.values()) v.box.value = v.initial
|
|
34
|
+
} else if (event === 'f') {
|
|
35
|
+
const s = byN.get(d[0])
|
|
36
|
+
if (s) s.done.value = true
|
|
37
|
+
} else if (event === 'e') {
|
|
38
|
+
const s = byN.get(d[0])
|
|
39
|
+
if (s)
|
|
40
|
+
s.error.value = new FnError(d[1], (d[1] as { code?: string }).code === 'A002' ? 401 : 500)
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/live/read.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEV,
|
|
3
|
+
createRoot,
|
|
4
|
+
derived,
|
|
5
|
+
effect,
|
|
6
|
+
getOwner,
|
|
7
|
+
onCleanup,
|
|
8
|
+
state,
|
|
9
|
+
untrack,
|
|
10
|
+
} from '@weft-js/core'
|
|
11
|
+
import type { Fn } from '../data'
|
|
12
|
+
import { currentScope, rt, skip, StartError } from './api'
|
|
13
|
+
import type { Sub, View } from './sub-types'
|
|
14
|
+
import type { Stream, StreamOptions } from './types'
|
|
15
|
+
|
|
16
|
+
export function read(
|
|
17
|
+
fn: Fn,
|
|
18
|
+
input: unknown,
|
|
19
|
+
o: StreamOptions<never, unknown> = {},
|
|
20
|
+
acquire: (fn: Fn, input: unknown) => Sub,
|
|
21
|
+
held: (s: Sub, keep?: string | number) => () => void,
|
|
22
|
+
): Stream<unknown> {
|
|
23
|
+
if (!getOwner())
|
|
24
|
+
throw new StartError(
|
|
25
|
+
'W051',
|
|
26
|
+
DEV
|
|
27
|
+
? `\`${fn.$id}()\` (a stream) was called outside a component or scope: nothing would close it`
|
|
28
|
+
: fn.$id,
|
|
29
|
+
DEV
|
|
30
|
+
? 'call it in a component, a `defineState` setup or `createRoot(dispose => …)`'
|
|
31
|
+
: undefined,
|
|
32
|
+
)
|
|
33
|
+
const scope = currentScope()
|
|
34
|
+
if (scope) {
|
|
35
|
+
scope.live = true // the server renders `initial`, and the client hydrates to the same
|
|
36
|
+
return { value: o.initial, status: rt.status, error: undefined }
|
|
37
|
+
}
|
|
38
|
+
const view = state<{ s: Sub; v: View } | undefined>(undefined)
|
|
39
|
+
const fold = o.fold ? String(o.fold) : ''
|
|
40
|
+
effect(
|
|
41
|
+
() => {
|
|
42
|
+
const arg = typeof input === 'function' ? (input as () => unknown)() : input
|
|
43
|
+
if (arg === skip) return undefined
|
|
44
|
+
return untrack(() => {
|
|
45
|
+
const s = acquire(fn, arg)
|
|
46
|
+
if (o.fold) s.buffer ??= o.buffer ?? 256
|
|
47
|
+
else if (o.buffer) s.buffer ??= o.buffer
|
|
48
|
+
let v = s.views.get(fold)
|
|
49
|
+
if (!v)
|
|
50
|
+
s.views.set(
|
|
51
|
+
fold,
|
|
52
|
+
(v = createRoot(
|
|
53
|
+
() => ({ fold: o.fold, initial: o.initial, box: state<unknown>(o.initial) }),
|
|
54
|
+
null,
|
|
55
|
+
)),
|
|
56
|
+
)
|
|
57
|
+
v.fold = o.fold
|
|
58
|
+
onCleanup(held(s, o.keep))
|
|
59
|
+
return { s, v }
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
(now) => {
|
|
63
|
+
view.value = now
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
const status = derived(() => (view.value?.s.done.value ? 'done' : rt.status.value))
|
|
67
|
+
return {
|
|
68
|
+
get value() {
|
|
69
|
+
const now = view.value
|
|
70
|
+
return now ? now.v.box.value : o.initial
|
|
71
|
+
},
|
|
72
|
+
status,
|
|
73
|
+
get error() {
|
|
74
|
+
return view.value?.s.error.value
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { State } from '@weft-js/core'
|
|
2
|
+
import type { Fn } from '../data'
|
|
3
|
+
|
|
4
|
+
export interface View {
|
|
5
|
+
fold?: ((acc: never, m: never) => unknown) | undefined
|
|
6
|
+
initial: unknown
|
|
7
|
+
box: State<unknown>
|
|
8
|
+
}
|
|
9
|
+
export interface Sub {
|
|
10
|
+
n: number
|
|
11
|
+
key: string
|
|
12
|
+
fn: Fn
|
|
13
|
+
input: unknown
|
|
14
|
+
refs: number
|
|
15
|
+
timer?: ReturnType<typeof setTimeout> | undefined
|
|
16
|
+
/** Stream: the last id, sent back on a resume. Query: its tags, from the ack. */
|
|
17
|
+
cursor?: string | undefined
|
|
18
|
+
tags: string[]
|
|
19
|
+
buffer?: number
|
|
20
|
+
acked: boolean
|
|
21
|
+
views: Map<string, View>
|
|
22
|
+
error: State<unknown>
|
|
23
|
+
done: State<boolean>
|
|
24
|
+
}
|