@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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The change feed's triggers, as SQL (`weft db live`, and tests). A trigger says which table and which primary keys
|
|
2
|
+
// changed, never a column of the row: a notification can be read by anyone who can LISTEN, and the hub only needs tags.
|
|
3
|
+
//
|
|
4
|
+
// postgresql statement triggers with transition tables, so 10k rows in one statement are one NOTIFY: up to 32 ids, or
|
|
5
|
+
// none past that, which means "this whole table". NOTIFY is in the transaction: a rollback sends nothing.
|
|
6
|
+
// sqlite no notify across processes: rows in a capped log, which the watcher reads when `data_version` moves.
|
|
7
|
+
// mysql the same log, read on a timer. A MySQL trigger is per row and can't reach out.
|
|
8
|
+
|
|
9
|
+
export const CHANNEL = 'weft_changes'
|
|
10
|
+
export const LOG = '__weft_changes'
|
|
11
|
+
/** More ids than this in one statement is a table-level change. */
|
|
12
|
+
export const MAX_IDS = 32
|
|
13
|
+
/** Rows SQLite's log keeps. A reader further behind than this has lost its place, and pokes every table. */
|
|
14
|
+
export const LOG_ROWS = 4096
|
|
15
|
+
export const BREAK = '\n--> statement-breakpoint\n'
|
|
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, '_')
|
|
21
|
+
|
|
22
|
+
function postgres(tables) {
|
|
23
|
+
const fn = `CREATE OR REPLACE FUNCTION weft_changes_notify() RETURNS trigger AS $weft$
|
|
24
|
+
DECLARE
|
|
25
|
+
ids text[];
|
|
26
|
+
body text;
|
|
27
|
+
BEGIN
|
|
28
|
+
IF TG_OP <> 'TRUNCATE' AND TG_NARGS > 0 THEN
|
|
29
|
+
EXECUTE format('SELECT array_agg(id) FROM (SELECT concat_ws(%L, %s) AS id FROM weft_rows LIMIT ${MAX_IDS + 1}) s', ':', (
|
|
30
|
+
SELECT string_agg(format('%I', a), ', ') FROM unnest(TG_ARGV) a
|
|
31
|
+
)) INTO ids;
|
|
32
|
+
IF ids IS NULL THEN RETURN NULL; END IF;
|
|
33
|
+
IF cardinality(ids) <= ${MAX_IDS} THEN
|
|
34
|
+
body := json_build_object('t', TG_TABLE_NAME, 'i', ids)::text;
|
|
35
|
+
END IF;
|
|
36
|
+
END IF;
|
|
37
|
+
IF body IS NULL OR octet_length(body) > 7900 THEN
|
|
38
|
+
body := json_build_object('t', TG_TABLE_NAME)::text;
|
|
39
|
+
END IF;
|
|
40
|
+
PERFORM pg_notify(${literal(CHANNEL)}, body);
|
|
41
|
+
RETURN NULL;
|
|
42
|
+
END;
|
|
43
|
+
$weft$ LANGUAGE plpgsql`
|
|
44
|
+
const list = tables.flatMap(({ name, pk }) => {
|
|
45
|
+
const args = pk.map(literal).join(', ')
|
|
46
|
+
const on = pgName(name)
|
|
47
|
+
const trigger = (suffix, event, rows) => [
|
|
48
|
+
`DROP TRIGGER IF EXISTS weft_changes_${suffix} ON ${on}`,
|
|
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
|
+
]
|
|
51
|
+
return [...trigger('ins', 'INSERT', 'NEW'), ...trigger('upd', 'UPDATE', 'NEW'), ...trigger('del', 'DELETE', 'OLD'), ...trigger('trn', 'TRUNCATE')]
|
|
52
|
+
})
|
|
53
|
+
return [fn, ...list]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sqlite(tables) {
|
|
57
|
+
const log = `CREATE TABLE IF NOT EXISTS ${LOG} (seq INTEGER PRIMARY KEY AUTOINCREMENT, tbl TEXT NOT NULL, pk TEXT)`
|
|
58
|
+
const list = tables.flatMap(({ name, pk }) => {
|
|
59
|
+
const id = row => (pk.length ? pk.map(c => `${row}.${pgName(c)}`).join(` || ':' || `) : 'NULL')
|
|
60
|
+
const trigger = (suffix, event, row) => [
|
|
61
|
+
`DROP TRIGGER IF EXISTS weft_changes_${safe(name)}_${suffix}`,
|
|
62
|
+
`CREATE TRIGGER weft_changes_${safe(name)}_${suffix} AFTER ${event} ON ${pgName(name)} BEGIN
|
|
63
|
+
INSERT INTO ${LOG} (tbl, pk) VALUES (${literal(name)}, ${id(row)});
|
|
64
|
+
DELETE FROM ${LOG} WHERE seq <= last_insert_rowid() - ${LOG_ROWS};
|
|
65
|
+
END`,
|
|
66
|
+
]
|
|
67
|
+
return [...trigger('ins', 'INSERT', 'NEW'), ...trigger('upd', 'UPDATE', 'NEW'), ...trigger('del', 'DELETE', 'OLD')]
|
|
68
|
+
})
|
|
69
|
+
return [log, ...list]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mysql(tables) {
|
|
73
|
+
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
|
+
const list = tables.flatMap(({ name, pk }) => {
|
|
75
|
+
const id = row => (pk.length ? `CONCAT_WS(':', ${pk.map(c => `${row}.${myName(c)}`).join(', ')})` : 'NULL')
|
|
76
|
+
const trigger = (suffix, event, row) => [
|
|
77
|
+
`DROP TRIGGER IF EXISTS weft_changes_${safe(name)}_${suffix}`,
|
|
78
|
+
`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
|
+
]
|
|
80
|
+
return [...trigger('ins', 'INSERT', 'NEW'), ...trigger('upd', 'UPDATE', 'NEW'), ...trigger('del', 'DELETE', 'OLD')]
|
|
81
|
+
})
|
|
82
|
+
return [log, ...list]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The statements that make `tables` report their changes. Each can run twice.
|
|
87
|
+
* @param {'postgresql' | 'sqlite' | 'mysql'} dialect
|
|
88
|
+
* @param {{ name: string, pk: string[] }[]} tables
|
|
89
|
+
* @returns {string[]}
|
|
90
|
+
*/
|
|
91
|
+
export function changeStatements(dialect, tables) {
|
|
92
|
+
if (dialect === 'postgresql') return postgres(tables)
|
|
93
|
+
if (dialect === 'sqlite') return sqlite(tables)
|
|
94
|
+
if (dialect === 'mysql') return mysql(tables)
|
|
95
|
+
throw new Error(`no change triggers for ${String(dialect)}`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A migration file, in drizzle-kit's format. */
|
|
99
|
+
export const changeSql = (dialect, tables) => `-- 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`
|
package/src/changes.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Writes from outside the app reach `.live` queries. A database tells this module which table and ids changed; it turns
|
|
2
|
+
// those into tags and the hub pokes whoever subscribed to them. Clients refetch through their own `query`, so `auth`
|
|
3
|
+
// runs per user and no row travels with a poke: a user who can't see a row gets nothing new.
|
|
4
|
+
//
|
|
5
|
+
// hold A change waits `hold` ms. A mutation's poke covers what committed before it, so an app write needs no
|
|
6
|
+
// second poke: before poking, the mutation sends a barrier down the same feed, which arrives after every
|
|
7
|
+
// change committed earlier. Whatever a barrier's tags cover is dropped. No clock is involved, and a change
|
|
8
|
+
// the barrier doesn't cover (cron, psql, a job with no `invalidates`) is delivered. Too slow a barrier costs
|
|
9
|
+
// one extra refetch, never a missed one.
|
|
10
|
+
// local Every instance listens and pokes only its own connections: each client gets one poke with no leader to
|
|
11
|
+
// elect. `listener: 'external'` is the other way round: one process listens, and publishes to the bus.
|
|
12
|
+
// rate After a flush the next waits `throttle` ms, so a writer in a loop can't drive refetches at its own pace.
|
|
13
|
+
//
|
|
14
|
+
// `q.live()` shows the mode (`liveMode`) and `degraded` or `reconnecting`, because a page that merely looks live is worse
|
|
15
|
+
// than one that says it isn't. Sources are in db/changes-*.ts; each probes what it can do before it says so.
|
|
16
|
+
|
|
17
|
+
import { StartError } from './data'
|
|
18
|
+
import type { DbHandle } from './db/core'
|
|
19
|
+
|
|
20
|
+
export type { LiveMode } from './data'
|
|
21
|
+
|
|
22
|
+
/** What changed: never what it changed to. No `id`: the whole table. */
|
|
23
|
+
export interface Change { table: string; id?: string }
|
|
24
|
+
|
|
25
|
+
/** What a source learned about this database, by trying it. `feed` sees every change; `poll` only estimates. */
|
|
26
|
+
export interface Probe {
|
|
27
|
+
mode: 'feed' | 'poll' | 'off'
|
|
28
|
+
/** `postgres-notify`, `sqlite-data-version`, `mysql-log`, `mongo-change-stream`, `postgres-stats`… */
|
|
29
|
+
via: string
|
|
30
|
+
/** Why this and not something better. */
|
|
31
|
+
why?: string
|
|
32
|
+
fix?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Where a source says what changed. */
|
|
36
|
+
export interface Sink {
|
|
37
|
+
changes(list: Change[]): void
|
|
38
|
+
/** A barrier arrived: what came before it and these tags cover is on its way as a poke. */
|
|
39
|
+
settled(tags: string[]): void
|
|
40
|
+
/** Changes may have gone unseen (a reconnect): every table is poked. */
|
|
41
|
+
lost(): void
|
|
42
|
+
down(why: string): void
|
|
43
|
+
up(): void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A change feed: what reports writes to the database, whoever made them. */
|
|
47
|
+
export interface Source {
|
|
48
|
+
readonly name: string
|
|
49
|
+
readonly tables: string[]
|
|
50
|
+
/** `listener: 'external'`: a separate process listens, and this app only reads the bus. */
|
|
51
|
+
readonly external?: boolean | undefined
|
|
52
|
+
/** Dev: the tags of one change. Default `[table, `${table}:${id}`]`. */
|
|
53
|
+
readonly tagged?: ((change: Change) => string[]) | undefined
|
|
54
|
+
/** Ms a change waits for its barrier, when a feed delivers in slower batches than the default 25. */
|
|
55
|
+
readonly hold?: number
|
|
56
|
+
/** The migration's input (`weft db live`). */
|
|
57
|
+
defs?(): Promise<TableDef[]>
|
|
58
|
+
readonly dialect?: string
|
|
59
|
+
probe(): Promise<Probe>
|
|
60
|
+
/** Starts from `probe`'s answer; resolves listening, with the way to stop. */
|
|
61
|
+
start(sink: Sink, probe: Probe): Promise<() => unknown>
|
|
62
|
+
/** Send a barrier through the feed. Omitted: only the hold window dedupes. */
|
|
63
|
+
settle?(tags: string[]): Promise<void>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A table as `Source.defs` reports it. */
|
|
67
|
+
export interface TableDef { name: string; pk: string[] }
|
|
68
|
+
|
|
69
|
+
/** `dbChanges`: how to listen, and how a change turns into tags. */
|
|
70
|
+
export interface ChangeOptions {
|
|
71
|
+
/** Direct `postgres://…` for the listener, when the app's URL goes through a pooler. */
|
|
72
|
+
listenUrl?: string
|
|
73
|
+
/** `'external'`: a separately deployed listener publishes to the bus (`listen` from `@weft-js/start/listen`). */
|
|
74
|
+
listener?: 'external'
|
|
75
|
+
/** Map a change to tags your queries use. Default `[table, `${table}:${id}`]`. */
|
|
76
|
+
tagged?: (change: Change) => string[]
|
|
77
|
+
/** Ms between looks where the database can't push: SQLite's `data_version` (100), MySQL's log (500), Postgres's stats (2000). */
|
|
78
|
+
interval?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const NAME = Symbol.for('drizzle:Name')
|
|
82
|
+
const nameOf = (t: unknown): string => (typeof t === 'string' ? t : (t as Record<symbol, string> | null)?.[NAME] ?? (() => { throw new StartError('R017', `\`dbChanges\` got something that isn't a table: ${String(t)}`, 'pass Drizzle tables, or their names') })())
|
|
83
|
+
|
|
84
|
+
const CORE = { sqlite: 'sqlite-core', postgresql: 'pg-core', mysql: 'mysql-core' }
|
|
85
|
+
|
|
86
|
+
/** A table's primary key columns, from its Drizzle definition. */
|
|
87
|
+
interface Key { name: string; primary?: boolean }
|
|
88
|
+
|
|
89
|
+
async function pkOf(table: unknown, dialect: DbHandle['dialect']): Promise<string[]> {
|
|
90
|
+
const { getTableConfig } = await import(/* @vite-ignore */ `drizzle-orm/${CORE[dialect]}`)
|
|
91
|
+
const { columns, primaryKeys } = getTableConfig(table)
|
|
92
|
+
const inline = columns.filter((c: Key) => c.primary).map((c: Key) => c.name)
|
|
93
|
+
return inline.length ? inline : (primaryKeys[0]?.columns ?? []).map((c: Key) => c.name)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `realtime({ changes: dbChanges(db, [todos, notes]) })`: these tables' writes reach `.live`, whoever made them. */
|
|
97
|
+
export function dbChanges(db: { $weft?: DbHandle }, tables: unknown[], options: ChangeOptions = {}): Source {
|
|
98
|
+
const handle = db?.$weft
|
|
99
|
+
if (!handle?.changes) throw new StartError('R017', '`dbChanges` needs a client from `@weft-js/start/db/sqlite`, `/postgres` or `/mysql`', 'for MongoDB use `mongoChanges` from `@weft-js/db/mongo`; for another client, write a `Source`')
|
|
100
|
+
const names = tables.map(nameOf)
|
|
101
|
+
let source: Promise<Omit<Source, 'name' | 'tables'>> | undefined
|
|
102
|
+
const load = () => (source ??= handle.changes!(names, options))
|
|
103
|
+
return {
|
|
104
|
+
name: handle.dialect,
|
|
105
|
+
dialect: handle.dialect,
|
|
106
|
+
tables: names,
|
|
107
|
+
external: options.listener === 'external',
|
|
108
|
+
tagged: options.tagged,
|
|
109
|
+
defs: () => Promise.all(tables.map(async t => ({ name: nameOf(t), pk: typeof t === 'string' ? [] : await pkOf(t, handle.dialect) }))),
|
|
110
|
+
probe: () => load().then(s => s.probe()),
|
|
111
|
+
start: (sink, probe) => load().then(s => s.start(sink, probe)),
|
|
112
|
+
settle: tags => load().then(s => s.settle?.(tags)),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Set by a platform, read at run time: this process lives for one request. */
|
|
117
|
+
export function functionHost(env: Record<string, string | undefined> = (globalThis as unknown as { process?: { env: Record<string, string | undefined> } }).process?.env ?? {}): string | undefined {
|
|
118
|
+
if (env.VERCEL) return 'Vercel'
|
|
119
|
+
if (env.NETLIFY || env.NETLIFY_LOCAL) return 'Netlify'
|
|
120
|
+
if (env.AWS_LAMBDA_FUNCTION_NAME) return 'AWS Lambda'
|
|
121
|
+
if (env.AZURE_FUNCTIONS_ENVIRONMENT) return 'Azure Functions'
|
|
122
|
+
if (env.FUNCTION_TARGET) return 'Cloud Functions'
|
|
123
|
+
if (globalThis.navigator?.userAgent === 'Cloudflare-Workers') return 'Cloudflare Workers'
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** What a change sends to live queries, as tags to refetch. */
|
|
128
|
+
export interface Poke { t: string[]; /** Tag prefixes: `todos:` for every row of a table-level change. */ p?: string[]; /** Tags a barrier covered: a subscription with one of them is poked already. */ k?: string[] }
|
|
129
|
+
|
|
130
|
+
/** Where a feed's pokes go: this instance, and the rest through the bus. */
|
|
131
|
+
export interface FeedOut {
|
|
132
|
+
emit(poke: Poke): void
|
|
133
|
+
/** Bumps the shared cache, so a refetch can't be answered with what the change made stale. */
|
|
134
|
+
bump?(tags: string[]): void
|
|
135
|
+
status(down: string | undefined): void
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A running feed, between its source and the hub. */
|
|
139
|
+
export interface Feed {
|
|
140
|
+
start(probe: Probe): Promise<void>
|
|
141
|
+
stop(): Promise<void>
|
|
142
|
+
/** Before a mutation's poke. Never throws: a dead feed must not fail a write that committed. */
|
|
143
|
+
settle(tags: string[]): Promise<void>
|
|
144
|
+
covers(tag: string): boolean
|
|
145
|
+
readonly running: boolean
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const unref = <T,>(t: T) => ((t as { unref?(): void } | null | undefined)?.unref?.(), t)
|
|
149
|
+
|
|
150
|
+
/** Batches a source's changes into pokes, held and throttled so a burst of writes is one refetch. */
|
|
151
|
+
export function createFeed(source: Source, out: FeedOut, { hold = source.hold ?? 25, throttle = 250, barrier = 1000 } = {}): Feed {
|
|
152
|
+
const known = new Set(source.tables)
|
|
153
|
+
let pending = new Set<string>(), prefixes = new Set<string>(), covered = new Set<string>()
|
|
154
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
155
|
+
let last = 0
|
|
156
|
+
let first: number | undefined
|
|
157
|
+
let stopping: (() => unknown) | undefined
|
|
158
|
+
let running = false
|
|
159
|
+
let isDown = false
|
|
160
|
+
|
|
161
|
+
const flush = () => {
|
|
162
|
+
timer = first = undefined
|
|
163
|
+
last = Date.now()
|
|
164
|
+
const t = [...pending], p = [...prefixes], k = [...covered]
|
|
165
|
+
pending = new Set()
|
|
166
|
+
prefixes = new Set()
|
|
167
|
+
covered = new Set()
|
|
168
|
+
if (t.length || p.length) out.emit({ t, ...(p.length && { p }), ...(k.length && { k }) })
|
|
169
|
+
}
|
|
170
|
+
/** Each change gets its `hold`, for a barrier to reach it; a writer that never stops gets a flush per `throttle`. */
|
|
171
|
+
const arm = () => {
|
|
172
|
+
const now = Date.now()
|
|
173
|
+
first ??= now
|
|
174
|
+
clearTimeout(timer)
|
|
175
|
+
timer = unref(setTimeout(flush, Math.max(Math.min(hold, first + throttle - now), last + throttle - now)))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const tagsOf = (change: Change) => source.tagged?.(change) ?? (change.id === undefined ? [change.table] : [change.table, `${change.table}:${change.id}`])
|
|
179
|
+
|
|
180
|
+
const sink: Sink = {
|
|
181
|
+
changes(list) {
|
|
182
|
+
const seen: string[] = []
|
|
183
|
+
for (const c of list) {
|
|
184
|
+
if (!known.has(c.table)) continue
|
|
185
|
+
for (const t of tagsOf(c)) {
|
|
186
|
+
pending.add(t)
|
|
187
|
+
covered.delete(t) // a change after the barrier: no poke covers it
|
|
188
|
+
seen.push(t)
|
|
189
|
+
}
|
|
190
|
+
if (c.id === undefined) prefixes.add(`${c.table}:`)
|
|
191
|
+
}
|
|
192
|
+
if (!seen.length) return
|
|
193
|
+
out.bump?.(seen)
|
|
194
|
+
arm()
|
|
195
|
+
},
|
|
196
|
+
settled(tags) {
|
|
197
|
+
for (const t of tags) {
|
|
198
|
+
if (pending.delete(t)) covered.add(t)
|
|
199
|
+
else if (prefixes.delete(`${t}:`)) covered.add(t)
|
|
200
|
+
}
|
|
201
|
+
if (pending.size || prefixes.size) return
|
|
202
|
+
clearTimeout(timer)
|
|
203
|
+
timer = first = undefined
|
|
204
|
+
covered = new Set()
|
|
205
|
+
},
|
|
206
|
+
lost() {
|
|
207
|
+
sink.changes(source.tables.map(table => ({ table })))
|
|
208
|
+
},
|
|
209
|
+
down(why) {
|
|
210
|
+
if (isDown) return
|
|
211
|
+
isDown = true
|
|
212
|
+
out.status(why)
|
|
213
|
+
},
|
|
214
|
+
up() {
|
|
215
|
+
if (!isDown) return
|
|
216
|
+
isDown = false
|
|
217
|
+
out.status(undefined)
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
get running() { return running },
|
|
223
|
+
covers(tag) {
|
|
224
|
+
const at = tag.indexOf(':')
|
|
225
|
+
return known.has(at < 0 ? tag : tag.slice(0, at))
|
|
226
|
+
},
|
|
227
|
+
async start(probe) {
|
|
228
|
+
if (running) return
|
|
229
|
+
running = true
|
|
230
|
+
try {
|
|
231
|
+
stopping = await source.start(sink, probe)
|
|
232
|
+
} catch (e) {
|
|
233
|
+
running = false
|
|
234
|
+
throw e
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
async stop() {
|
|
238
|
+
clearTimeout(timer)
|
|
239
|
+
timer = undefined
|
|
240
|
+
running = false
|
|
241
|
+
const stop = stopping
|
|
242
|
+
stopping = undefined
|
|
243
|
+
await stop?.()
|
|
244
|
+
},
|
|
245
|
+
async settle(tags) {
|
|
246
|
+
const ours = tags.filter(t => this.covers(t))
|
|
247
|
+
if (!ours.length || !source.settle) return
|
|
248
|
+
let late: ReturnType<typeof setTimeout> | undefined
|
|
249
|
+
await Promise.race([source.settle(ours), new Promise<void>(ok => { late = unref(setTimeout(ok, barrier)) })]).catch(() => {})
|
|
250
|
+
clearTimeout(late)
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @weft-js/core/test's socket (research 32 §7): the real socket.ts and hub, a memory bus, frames through JSON.
|
|
2
|
+
|
|
3
|
+
import { fakeScope, fns, net, runtime, track } from './data'
|
|
4
|
+
import type { Frame, SocketLink } from './channel'
|
|
5
|
+
|
|
6
|
+
/** A socket to the real server code in this process. */
|
|
7
|
+
const exec_ = async () => (runtime.exec ??= await import('./exec'))
|
|
8
|
+
|
|
9
|
+
export async function open(frame: (d: Frame) => void, done: (code: number) => void, up: () => void): Promise<SocketLink> {
|
|
10
|
+
const n = net()!
|
|
11
|
+
const [{ hub, resetHub }, { attach, publish }, exec] = await Promise.all([import('./hub'), import('./socket'), exec_()])
|
|
12
|
+
if (!n.online()) throw new Error('offline')
|
|
13
|
+
if (!n.resets.includes(resetHub)) n.resets.push(resetHub)
|
|
14
|
+
const h = hub()
|
|
15
|
+
h.dev = true
|
|
16
|
+
runtime.poke ??= (tags, origin) => hub().poke(tags, origin)
|
|
17
|
+
const request = new Request('http://localhost/_weft/ws')
|
|
18
|
+
const rooms = new Map<number, string>()
|
|
19
|
+
const joining = new Map<number, () => void>()
|
|
20
|
+
let closed = false
|
|
21
|
+
const peer = {
|
|
22
|
+
send(t: string) {
|
|
23
|
+
const d = JSON.parse(t)
|
|
24
|
+
frame(d)
|
|
25
|
+
if (d[0] === 'j' || d[0] === 'e') joining.get(d[1])?.()
|
|
26
|
+
},
|
|
27
|
+
close(code: number) {
|
|
28
|
+
if (closed) return
|
|
29
|
+
closed = true
|
|
30
|
+
n.ws = undefined
|
|
31
|
+
for (const go of joining.values()) go()
|
|
32
|
+
done(code)
|
|
33
|
+
},
|
|
34
|
+
backlog: () => 0,
|
|
35
|
+
}
|
|
36
|
+
const socket = await attach(peer, request, fakeScope(exec, n, request), h)
|
|
37
|
+
n.ws = {
|
|
38
|
+
publish: (id, params, name, msg) => publish(fns.get(id)!, params, name, msg),
|
|
39
|
+
drop: () => { peer.close(1006); socket.close() }, // both ends, as a real cut: the server runs `left`
|
|
40
|
+
recheck: () => socket.recheck(),
|
|
41
|
+
}
|
|
42
|
+
up()
|
|
43
|
+
return {
|
|
44
|
+
send(text) {
|
|
45
|
+
const f = JSON.parse(text)
|
|
46
|
+
if (f[0] === 'm') n.sent.push({ id: rooms.get(f[1]) ?? '', name: f[3], msg: f[4] })
|
|
47
|
+
if (f[0] === 'j') {
|
|
48
|
+
rooms.set(f[1], f[2])
|
|
49
|
+
void track(new Promise<void>(ok => { joining.set(f[1], ok) })) // `net.settle()` waits for the join's answer
|
|
50
|
+
}
|
|
51
|
+
socket.message(text)
|
|
52
|
+
},
|
|
53
|
+
close: () => socket.close(),
|
|
54
|
+
}
|
|
55
|
+
}
|