evikit 2.7.3 → 2.8.0
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 +4 -1
- package/db.d.ts +3 -0
- package/package.json +1 -1
- package/query.js +39 -23
- package/server-generic.d.ts +8 -1
- package/server.d.ts +7 -1
- package/server.js +111 -3
- package/sqlite.js +139 -95
- package/use-api.js +74 -16
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Vite is a good foundation for building a server-rendered monolith SPA with an JS
|
|
|
6
6
|
|
|
7
7
|
- For each `/path`, fetch `/api/path` and SSR the page
|
|
8
8
|
- Send urlencoded forms and refresh `/api/path` without a full page reload
|
|
9
|
+
- Live-refresh `/api/path` whenever its database tables update
|
|
10
|
+
- Note: added in 2.8. To opt in, pass `db` to `router`
|
|
9
11
|
- Validate API input and output against your schemas
|
|
10
12
|
- Input is: cookies + url params + body (each overrides previous if key exists)
|
|
11
13
|
- Note: PHP [removed cookies from this list](https://stackoverflow.com/questions/51538622/php-ini-request-order-security-concerns#comment90071163_51538946) but I think it's ok if used carefully
|
|
@@ -194,6 +196,7 @@ Add an API router at `src/api.js` (uncomment the routes one by one when we add t
|
|
|
194
196
|
import { route, router } from "evikit/server";
|
|
195
197
|
|
|
196
198
|
import pkg from "../package.json" with { type: "json" };
|
|
199
|
+
import { db } from "./lib/db/index.js";
|
|
197
200
|
import { acceptLanguages } from "./lib/index.js";
|
|
198
201
|
// import { ChoiceServer } from "./routes/api/choice/server.js";
|
|
199
202
|
// import { QuestionIdServer } from "./routes/api/question/[id]/server.js";
|
|
@@ -204,7 +207,7 @@ acceptLanguages();
|
|
|
204
207
|
|
|
205
208
|
export function api() {
|
|
206
209
|
return router(
|
|
207
|
-
{ pkg },
|
|
210
|
+
{ db, pkg },
|
|
208
211
|
// route("/api", HomeServer),
|
|
209
212
|
// route("/api/choice", ChoiceServer),
|
|
210
213
|
// route("/api/question/:id", QuestionIdServer),
|
package/db.d.ts
CHANGED
package/package.json
CHANGED
package/query.js
CHANGED
|
@@ -6,6 +6,7 @@ import { useContext, useEffect, useState } from "preact/hooks";
|
|
|
6
6
|
* @property {any} data
|
|
7
7
|
* @property {number} dataUpdatedAt
|
|
8
8
|
* @property {unknown | null} error
|
|
9
|
+
* @property {boolean} fromEventSource
|
|
9
10
|
* @property {boolean} isInvalidated
|
|
10
11
|
* @property {"error" | "loading" | "success"} status
|
|
11
12
|
*/
|
|
@@ -37,18 +38,23 @@ export class QueryClient {
|
|
|
37
38
|
const queryHash = QueryHash(queryKey);
|
|
38
39
|
this.state.setQueries((queries) =>
|
|
39
40
|
queries.map((query) =>
|
|
40
|
-
query.queryHash == queryHash ||
|
|
41
|
-
|
|
41
|
+
(query.queryHash == queryHash ||
|
|
42
|
+
query.queryHash.startsWith(queryHash + "/")) &&
|
|
43
|
+
!query.state.fromEventSource
|
|
42
44
|
? { ...query, state: { ...query.state, isInvalidated: true } }
|
|
43
45
|
: query,
|
|
44
46
|
),
|
|
45
47
|
);
|
|
46
48
|
} else {
|
|
47
49
|
this.state.setQueries((queries) =>
|
|
48
|
-
queries.map((query) =>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
queries.map((query) =>
|
|
51
|
+
query.state.fromEventSource
|
|
52
|
+
? query
|
|
53
|
+
: {
|
|
54
|
+
...query,
|
|
55
|
+
state: { ...query.state, isInvalidated: true },
|
|
56
|
+
},
|
|
57
|
+
),
|
|
52
58
|
);
|
|
53
59
|
}
|
|
54
60
|
}
|
|
@@ -56,19 +62,7 @@ export class QueryClient {
|
|
|
56
62
|
/** @param {{ queryKey: string[], queryFn: () => Promise<any> }} props */
|
|
57
63
|
async prefetchQuery({ queryFn, queryKey }) {
|
|
58
64
|
const queryHash = QueryHash(queryKey);
|
|
59
|
-
|
|
60
|
-
const setQueryState = (map) =>
|
|
61
|
-
this.state.setQueries((queries) => {
|
|
62
|
-
const state = map(queries);
|
|
63
|
-
const existing = queries.find(
|
|
64
|
-
(query) => query.queryHash == queryHash,
|
|
65
|
-
)?.state;
|
|
66
|
-
return existing
|
|
67
|
-
? queries.map((query) =>
|
|
68
|
-
query.state == existing ? { queryHash, state } : query,
|
|
69
|
-
)
|
|
70
|
-
: [...queries, { queryHash, state }];
|
|
71
|
-
});
|
|
65
|
+
const setQueryState = SetQueryState({ queryClient: this, queryKey });
|
|
72
66
|
|
|
73
67
|
const state = this.state.queries.find(
|
|
74
68
|
(query) => query.queryHash == queryHash,
|
|
@@ -76,6 +70,7 @@ export class QueryClient {
|
|
|
76
70
|
if (
|
|
77
71
|
!state ||
|
|
78
72
|
(state.status != "loading" &&
|
|
73
|
+
!state.fromEventSource &&
|
|
79
74
|
(state.isInvalidated ||
|
|
80
75
|
state.dataUpdatedAt <
|
|
81
76
|
Date.now() - this.defaultOptions.queries.staleTime))
|
|
@@ -84,6 +79,7 @@ export class QueryClient {
|
|
|
84
79
|
data: state?.data ?? null,
|
|
85
80
|
dataUpdatedAt: state?.dataUpdatedAt ?? 0,
|
|
86
81
|
error: state?.error ?? null,
|
|
82
|
+
fromEventSource: state?.fromEventSource ?? false,
|
|
87
83
|
isInvalidated: false,
|
|
88
84
|
status: "loading",
|
|
89
85
|
}));
|
|
@@ -93,16 +89,16 @@ export class QueryClient {
|
|
|
93
89
|
data,
|
|
94
90
|
dataUpdatedAt: Date.now(),
|
|
95
91
|
error: null,
|
|
92
|
+
fromEventSource: false,
|
|
96
93
|
isInvalidated: false,
|
|
97
94
|
status: "success",
|
|
98
95
|
}));
|
|
99
96
|
} catch (error) {
|
|
100
|
-
setQueryState((
|
|
101
|
-
data:
|
|
102
|
-
queries.find((query) => query.queryHash == queryHash)?.state.data ??
|
|
103
|
-
null,
|
|
97
|
+
setQueryState((existing) => ({
|
|
98
|
+
data: existing?.data ?? null,
|
|
104
99
|
dataUpdatedAt: Date.now(),
|
|
105
100
|
error,
|
|
101
|
+
fromEventSource: false,
|
|
106
102
|
isInvalidated: false,
|
|
107
103
|
status: "error",
|
|
108
104
|
}));
|
|
@@ -141,6 +137,26 @@ export function dehydrate(queryClient, options) {
|
|
|
141
137
|
};
|
|
142
138
|
}
|
|
143
139
|
|
|
140
|
+
/** @param {{ queryClient: QueryClient, queryKey: string[] }} props */
|
|
141
|
+
export function SetQueryState({ queryClient, queryKey }) {
|
|
142
|
+
const queryHash = QueryHash(queryKey);
|
|
143
|
+
/** @param {(existing: QueryState | undefined) => QueryState | undefined} map */
|
|
144
|
+
return (map) =>
|
|
145
|
+
queryClient.state.setQueries((queries) => {
|
|
146
|
+
const existing = queries.find(
|
|
147
|
+
(query) => query.queryHash == queryHash,
|
|
148
|
+
)?.state;
|
|
149
|
+
const state = map(existing);
|
|
150
|
+
return state
|
|
151
|
+
? existing
|
|
152
|
+
? queries.map((query) =>
|
|
153
|
+
query.state == existing ? { queryHash, state } : query,
|
|
154
|
+
)
|
|
155
|
+
: [...queries, { queryHash, state }]
|
|
156
|
+
: queries;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
144
160
|
const QueryClientContext = createContext(
|
|
145
161
|
/** @type {QueryClient} */ (/** @type {any} */ (undefined)),
|
|
146
162
|
);
|
package/server-generic.d.ts
CHANGED
|
@@ -16,7 +16,14 @@ import {
|
|
|
16
16
|
export type MethodServer = Record<
|
|
17
17
|
string,
|
|
18
18
|
{
|
|
19
|
-
handler(
|
|
19
|
+
handler(
|
|
20
|
+
req: IncomingMessage,
|
|
21
|
+
res: {
|
|
22
|
+
end(body?: string): void;
|
|
23
|
+
setHeader(name: string, value: string): void;
|
|
24
|
+
set statusCode(code: number): void;
|
|
25
|
+
},
|
|
26
|
+
): void;
|
|
20
27
|
input: ObjectSchema<ObjectEntries, undefined>;
|
|
21
28
|
outputs: Record<
|
|
22
29
|
number,
|
package/server.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
|
|
3
|
+
import type { Db } from "./db.d.ts";
|
|
3
4
|
import type {
|
|
4
5
|
MethodServer,
|
|
5
6
|
ServerEndpoint,
|
|
@@ -57,10 +58,15 @@ export function route(
|
|
|
57
58
|
pattern: URLPattern;
|
|
58
59
|
};
|
|
59
60
|
|
|
60
|
-
export function router
|
|
61
|
+
export function router<
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
Schema extends Record<string, ObjectSchema<any, any>>,
|
|
64
|
+
>(
|
|
61
65
|
{
|
|
66
|
+
db,
|
|
62
67
|
pkg,
|
|
63
68
|
}: {
|
|
69
|
+
db?: Db<Schema>;
|
|
64
70
|
pkg: { name: string; version: string };
|
|
65
71
|
},
|
|
66
72
|
...routes: {
|
package/server.js
CHANGED
|
@@ -211,8 +211,20 @@ export function listen(app) {
|
|
|
211
211
|
Number(process.env["PARALLELISM"]) || availableParallelism();
|
|
212
212
|
const port = Number(process.env["PORT"]) || 5173;
|
|
213
213
|
if (cluster.isPrimary && parallelism > 1) {
|
|
214
|
+
/** @type {{ on(event: "message", callback: (message: any) => void): void, send(message: any, _: any, options: { keepOpen: boolean }): void }[]} */
|
|
215
|
+
const forks = [];
|
|
214
216
|
for (let i = 0; i < parallelism; i++) {
|
|
215
|
-
cluster.fork({ i });
|
|
217
|
+
forks.push(cluster.fork({ i }));
|
|
218
|
+
}
|
|
219
|
+
for (const fork of forks) {
|
|
220
|
+
fork.on("message", (maybeMessage) => {
|
|
221
|
+
const message = /** @type {any} */ (maybeMessage);
|
|
222
|
+
for (let i = 0; i < forks.length; i++) {
|
|
223
|
+
if (i != message.i) {
|
|
224
|
+
forks[i]?.send(message, undefined, { keepOpen: true });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
216
228
|
}
|
|
217
229
|
} else {
|
|
218
230
|
const server = app.listen(port, () => {
|
|
@@ -318,10 +330,11 @@ export function route(path, controller) {
|
|
|
318
330
|
}
|
|
319
331
|
|
|
320
332
|
/**
|
|
321
|
-
*
|
|
333
|
+
* // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
334
|
+
* @param {{ db?: import("./db.js").Db<any>, pkg: { name: string, version: string } }} props
|
|
322
335
|
* @param {ReturnType<typeof route>[]} routes
|
|
323
336
|
*/
|
|
324
|
-
export function router({ pkg }, ...routes) {
|
|
337
|
+
export function router({ db, pkg }, ...routes) {
|
|
325
338
|
const openapi = {
|
|
326
339
|
info: { title: pkg.name, version: pkg.version },
|
|
327
340
|
openapi: "3.1.0",
|
|
@@ -423,6 +436,46 @@ export function router({ pkg }, ...routes) {
|
|
|
423
436
|
|
|
424
437
|
const poPattern = new URLPattern(`http://*:*/po/:lang`);
|
|
425
438
|
|
|
439
|
+
/** @type {Map<(req: any, res: any) => void, string[]>} */
|
|
440
|
+
const TablesInOutput = new Map();
|
|
441
|
+
for (const { controller } of Object.values(routes)) {
|
|
442
|
+
for (const [method, { handler, outputs }] of Object.entries(controller)) {
|
|
443
|
+
if (method == "get") {
|
|
444
|
+
/** @type {string[]} */
|
|
445
|
+
let tablesInOutput = [];
|
|
446
|
+
|
|
447
|
+
/** @param {import("valibot").ArraySchema<any, any> | import("valibot").ObjectSchema<import("valibot").ObjectEntries, any> | import("valibot").StringSchema<any>} schema */
|
|
448
|
+
function walk(schema) {
|
|
449
|
+
if (schema.type == "array") {
|
|
450
|
+
walk(schema.item);
|
|
451
|
+
} else if (schema.type == "object") {
|
|
452
|
+
for (const entry of Object.values(schema.entries)) {
|
|
453
|
+
walk(/** @type {any} */ (entry));
|
|
454
|
+
}
|
|
455
|
+
} else if (db) {
|
|
456
|
+
for (const [name, table] of Object.entries(
|
|
457
|
+
/** @type {Record<string, import("valibot").ObjectSchema<import("valibot").ObjectEntries, any>>} */ (
|
|
458
|
+
db.schema
|
|
459
|
+
),
|
|
460
|
+
)) {
|
|
461
|
+
for (const column of Object.values(table.entries)) {
|
|
462
|
+
if (column == schema) {
|
|
463
|
+
tablesInOutput.push(name);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
for (const schema of Object.values(outputs)) {
|
|
470
|
+
walk(schema);
|
|
471
|
+
}
|
|
472
|
+
if (tablesInOutput.length) {
|
|
473
|
+
TablesInOutput.set(handler, [...new Set(tablesInOutput)]);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
426
479
|
/**
|
|
427
480
|
* @param {import("node:http").IncomingMessage} req
|
|
428
481
|
* @param {import("node:http").ServerResponse<import("node:http").IncomingMessage>} res
|
|
@@ -458,6 +511,61 @@ export function router({ pkg }, ...routes) {
|
|
|
458
511
|
}
|
|
459
512
|
}
|
|
460
513
|
req.url = url.pathname + (url.search || "");
|
|
514
|
+
|
|
515
|
+
if (method == "get" && req.headers.accept == "text/event-stream") {
|
|
516
|
+
res.setHeader("x-accel-buffering", "no");
|
|
517
|
+
res.setHeader("content-type", "text/event-stream");
|
|
518
|
+
res.setHeader("cache-control", "no-cache");
|
|
519
|
+
let lastData = "";
|
|
520
|
+
let lastId = 0;
|
|
521
|
+
const tablesInOutput = TablesInOutput.get(handler);
|
|
522
|
+
if (tablesInOutput && db) {
|
|
523
|
+
req.on(
|
|
524
|
+
"close",
|
|
525
|
+
db.subscribe(({ table }) => {
|
|
526
|
+
if (tablesInOutput.includes(table)) {
|
|
527
|
+
/** @type {Record<string, string>} */
|
|
528
|
+
const headers = {};
|
|
529
|
+
let statusCode = 200;
|
|
530
|
+
let id = ++lastId;
|
|
531
|
+
handler(req, {
|
|
532
|
+
end(body) {
|
|
533
|
+
if (id == lastId) {
|
|
534
|
+
const data = JSON.stringify({
|
|
535
|
+
body,
|
|
536
|
+
headers,
|
|
537
|
+
statusCode,
|
|
538
|
+
});
|
|
539
|
+
if (lastData != data) {
|
|
540
|
+
lastData = data;
|
|
541
|
+
res.write(`data: ${data}\n\n`);
|
|
542
|
+
const expressCompressionResponse =
|
|
543
|
+
/** @type {any} */ (res);
|
|
544
|
+
if (
|
|
545
|
+
typeof expressCompressionResponse.flush ==
|
|
546
|
+
"function"
|
|
547
|
+
) {
|
|
548
|
+
expressCompressionResponse.flush();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
setHeader(name, value) {
|
|
554
|
+
headers[name] = value;
|
|
555
|
+
},
|
|
556
|
+
set statusCode(/** @type {number} */ value) {
|
|
557
|
+
statusCode = value;
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
} else {
|
|
564
|
+
res.end("data: null\n\n");
|
|
565
|
+
}
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
461
569
|
handler(req, res);
|
|
462
570
|
return;
|
|
463
571
|
}
|
package/sqlite.js
CHANGED
|
@@ -277,109 +277,153 @@ export function sqlite({ pkg, schema }) {
|
|
|
277
277
|
database.exec(sql);
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
/** @
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
280
|
+
/** @type {(({ table }: { table: string }) => void)[]} */
|
|
281
|
+
const listeners = [];
|
|
282
|
+
if (process.env["i"]) {
|
|
283
|
+
process.on("message", (maybeMessage) => {
|
|
284
|
+
for (const onMessage of listeners) {
|
|
285
|
+
onMessage(/** @type {any} */ (maybeMessage));
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/** @param {{ table: string }} message */
|
|
290
|
+
function publish(message) {
|
|
291
|
+
Object.assign(message, { i: process.env["i"] });
|
|
292
|
+
process.send?.(message, undefined, { keepOpen: true });
|
|
293
|
+
for (const onMessage of listeners) {
|
|
294
|
+
onMessage(message);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
.../** @type {any} */ (
|
|
300
|
+
Object.fromEntries(
|
|
301
|
+
Object.entries(schema).map(([maybeTable]) => {
|
|
302
|
+
const table = /** @type {keyof typeof schema & string} */ (
|
|
303
|
+
maybeTable
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
/** @param {{ [K in keyof (typeof schema)[typeof table]["entries"]]?: import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]> | import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]>[]; }} where */
|
|
307
|
+
function ensureArrayRecord(where) {
|
|
308
|
+
for (const key of Object.keys(where)) {
|
|
309
|
+
if (!Array.isArray(where[key])) {
|
|
310
|
+
Object.assign(where, { [key]: [where[key]] });
|
|
311
|
+
}
|
|
290
312
|
}
|
|
291
313
|
}
|
|
292
|
-
}
|
|
293
314
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
315
|
+
ok(schema[table]);
|
|
316
|
+
const columns = Object.entries(schema[table].entries);
|
|
317
|
+
const primaryKey = columns.find(
|
|
318
|
+
([, column]) =>
|
|
319
|
+
/** @type {{ primary?: true }} */ (getMetadata(column)).primary,
|
|
320
|
+
);
|
|
321
|
+
ok(primaryKey);
|
|
322
|
+
const upsert = database.prepare(
|
|
323
|
+
`insert into ${table} (${columns.map(([name]) => name).join(", ")}) values\n` +
|
|
324
|
+
`(${columns.map(() => "?").join(", ")})\n` +
|
|
325
|
+
`on conflict (${primaryKey[0]}) do update set\n` +
|
|
326
|
+
columns.map(([name]) => `${name} = ?`).join(",\n"),
|
|
327
|
+
);
|
|
307
328
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
329
|
+
return [
|
|
330
|
+
table,
|
|
331
|
+
{
|
|
332
|
+
/** @param {{ [K in keyof (typeof schema)[typeof table]["entries"]]?: import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]> | import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]>[]; }} where */
|
|
333
|
+
delete(where) {
|
|
334
|
+
ensureArrayRecord(where);
|
|
335
|
+
if (
|
|
336
|
+
database
|
|
337
|
+
.prepare(
|
|
338
|
+
`delete from ${table} where true\n` +
|
|
339
|
+
Object.entries(where)
|
|
340
|
+
.map(
|
|
341
|
+
([name, values]) =>
|
|
342
|
+
`and ${name} in (${values.map(() => "?").join(", ")})`,
|
|
343
|
+
)
|
|
344
|
+
.join("\n"),
|
|
345
|
+
)
|
|
346
|
+
.run(
|
|
347
|
+
...Object.values(where)
|
|
348
|
+
.flatMap((values) => values)
|
|
349
|
+
.map((value) => value ?? null),
|
|
350
|
+
).changes
|
|
351
|
+
) {
|
|
352
|
+
publish({ table });
|
|
353
|
+
}
|
|
354
|
+
},
|
|
330
355
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
356
|
+
/** @param {any} join */
|
|
357
|
+
select(join) {
|
|
358
|
+
if (join?.where) {
|
|
359
|
+
ensureArrayRecord(join.where);
|
|
360
|
+
}
|
|
361
|
+
return select({ database, flat, join, schema, table });
|
|
362
|
+
},
|
|
338
363
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
364
|
+
/** @param {{ set: Partial<import("valibot").InferOutput<(typeof schema)[typeof table]>>, where: { [K in keyof (typeof schema)[typeof table]["entries"]]?: import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]> | import("valibot").InferOutput<(typeof schema)[typeof table]["entries"][K]>[]; } }} props */
|
|
365
|
+
update({ set, where }) {
|
|
366
|
+
ensureArrayRecord(where);
|
|
367
|
+
if (
|
|
368
|
+
database
|
|
369
|
+
.prepare(
|
|
370
|
+
`update ${table} set\n` +
|
|
371
|
+
Object.keys(set)
|
|
372
|
+
.map((name) => `${name} = ?`)
|
|
373
|
+
.join(",\n") +
|
|
374
|
+
`\nwhere true\n` +
|
|
375
|
+
Object.entries(where)
|
|
376
|
+
.map(
|
|
377
|
+
([name, values]) =>
|
|
378
|
+
`and ${name} in (${values.map(() => "?").join(", ")})`,
|
|
379
|
+
)
|
|
380
|
+
.join("\n"),
|
|
381
|
+
)
|
|
382
|
+
.run(
|
|
383
|
+
...[
|
|
384
|
+
...Object.values(set).map((value) => value ?? null),
|
|
385
|
+
...Object.values(where)
|
|
386
|
+
.flatMap((values) => values)
|
|
387
|
+
.map((value) => value ?? null),
|
|
388
|
+
],
|
|
389
|
+
).changes
|
|
390
|
+
) {
|
|
391
|
+
publish({ table });
|
|
392
|
+
}
|
|
393
|
+
},
|
|
365
394
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
395
|
+
/** @param {any[]} values */
|
|
396
|
+
upsert(...values) {
|
|
397
|
+
ok(schema[table]);
|
|
398
|
+
const outputs = [];
|
|
399
|
+
/** @type {boolean} */
|
|
400
|
+
let changes = false;
|
|
401
|
+
for (const row of values) {
|
|
402
|
+
const output = parse(schema[table], row);
|
|
403
|
+
const values = columns.map(([name]) => output[name] ?? null);
|
|
404
|
+
if (upsert.run(...values, ...values).changes) {
|
|
405
|
+
changes = true;
|
|
406
|
+
}
|
|
407
|
+
outputs.push(output);
|
|
408
|
+
}
|
|
409
|
+
if (changes) {
|
|
410
|
+
publish({ table });
|
|
411
|
+
}
|
|
412
|
+
return outputs;
|
|
413
|
+
},
|
|
377
414
|
},
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
)
|
|
382
|
-
|
|
415
|
+
];
|
|
416
|
+
}),
|
|
417
|
+
)
|
|
418
|
+
),
|
|
419
|
+
schema,
|
|
420
|
+
subscribe(onMessage) {
|
|
421
|
+
listeners.push(onMessage);
|
|
422
|
+
return () => {
|
|
423
|
+
listeners.splice(listeners.indexOf(onMessage), 1);
|
|
424
|
+
};
|
|
425
|
+
},
|
|
426
|
+
};
|
|
383
427
|
}
|
|
384
428
|
|
|
385
429
|
/** @type {<Schema extends Record<string, import("valibot").ObjectSchema<any, any>>, R extends import("./db.d.ts").Relations<Schema> = import("./db.d.ts").Relations<Schema>, F extends import("./db.d.ts").Flatten<Schema> = import("./db.d.ts").Flatten<Schema>, Table extends keyof F & keyof Schema = keyof F & keyof Schema >(props: { database: DatabaseSync; flat: F; join?: import("./db.d.ts").Join<R, Table> & { where?: { [K in keyof Schema[Table]["entries"]]?: import("valibot").InferOutput<Schema[Table]["entries"][K]> | import("valibot").InferOutput<Schema[Table]["entries"][K]>[]; } }; schema: Schema; table: Table; }) => any[]} */
|
package/use-api.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { useRoute } from "preact-iso";
|
|
2
|
+
import { useEffect, useState } from "preact/hooks";
|
|
2
3
|
import { parse } from "valibot";
|
|
3
4
|
|
|
4
5
|
import { jsdomCookie } from "./jsdom-cookie.js";
|
|
5
|
-
import { useQuery } from "./query.js";
|
|
6
|
+
import { SetQueryState, useQuery, useQueryClient } from "./query.js";
|
|
6
7
|
|
|
7
8
|
/** @param {{ lang?: string, origin: string | undefined, path: string, schema?: any }} props */
|
|
8
9
|
export function queryApi({ lang, origin, path, schema }) {
|
|
@@ -11,16 +12,12 @@ export function queryApi({ lang, origin, path, schema }) {
|
|
|
11
12
|
|
|
12
13
|
return {
|
|
13
14
|
queryFn: async () => {
|
|
14
|
-
const response = await fetch(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
headers: {
|
|
19
|
-
...(lang ? { "Accept-Language": lang } : {}),
|
|
20
|
-
Cookie: jsdomCookie.setCookie.join("; "),
|
|
21
|
-
},
|
|
15
|
+
const response = await fetch(ApiPath({ origin, path }), {
|
|
16
|
+
headers: {
|
|
17
|
+
...(lang ? { "Accept-Language": lang } : {}),
|
|
18
|
+
Cookie: jsdomCookie.setCookie.join("; "),
|
|
22
19
|
},
|
|
23
|
-
);
|
|
20
|
+
});
|
|
24
21
|
if (response.status >= 400) {
|
|
25
22
|
const error = new Error(await response.text());
|
|
26
23
|
error.name = response.status.toString();
|
|
@@ -35,13 +32,74 @@ export function queryApi({ lang, origin, path, schema }) {
|
|
|
35
32
|
|
|
36
33
|
/** @param {{ outputs: { 200: any } }} routeType */
|
|
37
34
|
export function useApi(routeType) {
|
|
38
|
-
const {
|
|
35
|
+
const { origin } = location;
|
|
36
|
+
const { path: pathWithoutSearch, query } = useRoute();
|
|
39
37
|
const search = new URLSearchParams(query).toString();
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
schema: routeType.outputs[200],
|
|
44
|
-
});
|
|
38
|
+
const path = pathWithoutSearch + (search ? `?${search}` : "");
|
|
39
|
+
const schema = routeType.outputs[200];
|
|
40
|
+
const { queryFn, queryKey } = queryApi({ origin, path, schema });
|
|
45
41
|
const { data, error } = useQuery({ queryFn, queryKey });
|
|
42
|
+
const queryClient = useQueryClient();
|
|
43
|
+
const setQueryState = SetQueryState({ queryClient, queryKey });
|
|
44
|
+
const [sourceUpdate, setSourceUpdate] = useState(Date.now());
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (typeof EventSource == "undefined") {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const source = new EventSource(ApiPath({ origin, path: path }));
|
|
50
|
+
source.onerror = () => {
|
|
51
|
+
setTimeout(() => {
|
|
52
|
+
setSourceUpdate(Date.now());
|
|
53
|
+
}, 1e3);
|
|
54
|
+
};
|
|
55
|
+
source.onmessage = ({ data }) => {
|
|
56
|
+
data = JSON.parse(data);
|
|
57
|
+
if (!data) {
|
|
58
|
+
source.close();
|
|
59
|
+
setQueryState((existing) =>
|
|
60
|
+
existing ? { ...existing, fromEventSource: false } : undefined,
|
|
61
|
+
);
|
|
62
|
+
} else if (Number(data.statusCode) >= 400) {
|
|
63
|
+
setQueryState((existing) => ({
|
|
64
|
+
data: existing?.data ?? null,
|
|
65
|
+
dataUpdatedAt: Date.now(),
|
|
66
|
+
error,
|
|
67
|
+
fromEventSource: true,
|
|
68
|
+
isInvalidated: false,
|
|
69
|
+
/** @type {"error"} */
|
|
70
|
+
status: "error",
|
|
71
|
+
}));
|
|
72
|
+
} else {
|
|
73
|
+
setQueryState(() => ({
|
|
74
|
+
data: schema
|
|
75
|
+
? parse(schema, JSON.parse(data.body))
|
|
76
|
+
: JSON.parse(data.body),
|
|
77
|
+
dataUpdatedAt: Date.now(),
|
|
78
|
+
error: null,
|
|
79
|
+
fromEventSource: true,
|
|
80
|
+
isInvalidated: false,
|
|
81
|
+
/** @type {"success"} */
|
|
82
|
+
status: "success",
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
setQueryState((existing) =>
|
|
87
|
+
existing ? { ...existing, fromEventSource: true } : undefined,
|
|
88
|
+
);
|
|
89
|
+
return () => {
|
|
90
|
+
source.close();
|
|
91
|
+
setQueryState((existing) =>
|
|
92
|
+
existing ? { ...existing, fromEventSource: false } : undefined,
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
}, [path, sourceUpdate]);
|
|
46
96
|
return { data, error };
|
|
47
97
|
}
|
|
98
|
+
|
|
99
|
+
/** @param {{ origin: string | undefined, path: string }} props */
|
|
100
|
+
function ApiPath({ origin, path }) {
|
|
101
|
+
return (
|
|
102
|
+
(origin || "") +
|
|
103
|
+
path.replace(/^\//, "/api/").replace(/^\/api\/(\?|$)/, "/api$1")
|
|
104
|
+
);
|
|
105
|
+
}
|