@voidbase-cloud/voidbase 0.1.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/.env.example +9 -0
- package/CHANGELOG.md +19 -0
- package/COMPAT.md +43 -0
- package/LICENSE +21 -0
- package/NOTICE +8 -0
- package/README.md +124 -0
- package/bin/voidbase.ts +158 -0
- package/crons/every-minute.ts +13 -0
- package/db/migrations/20260905175935_large_swarm.sql +87 -0
- package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
- package/db/migrations/20260905190723_solid_toro.sql +1 -0
- package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
- package/db/migrations/meta/20260905175935_snapshot.json +599 -0
- package/db/migrations/meta/20260905185720_snapshot.json +703 -0
- package/db/migrations/meta/20260905190723_snapshot.json +710 -0
- package/db/migrations/meta/20260905213340_snapshot.json +781 -0
- package/db/migrations/meta/_journal.json +34 -0
- package/db/schema.ts +130 -0
- package/docs/deploy.md +153 -0
- package/docs/differences.md +88 -0
- package/docs/hooks.md +84 -0
- package/docs/migrating.md +29 -0
- package/docs/perf.md +53 -0
- package/docs/platform.md +208 -0
- package/docs/releasing.md +38 -0
- package/env.ts +23 -0
- package/hooks-plugin.ts +237 -0
- package/package.json +134 -0
- package/queues/jobs.ts +13 -0
- package/routes/api/[...path].ts +19 -0
- package/scripts/bench-realtime.ts +46 -0
- package/scripts/bench.ts +39 -0
- package/scripts/ci-suites.sh +27 -0
- package/scripts/dev.sh +29 -0
- package/scripts/export.ts +70 -0
- package/scripts/seed-app-user.sh +14 -0
- package/scripts/seed-d1.ts +17 -0
- package/scripts/seed-reference.sh +29 -0
- package/scripts/starter.sh +22 -0
- package/scripts/sync-app.ts +22 -0
- package/scripts/sync-panel.ts +66 -0
- package/src/cloud/rest.ts +297 -0
- package/src/node/assets.ts +22 -0
- package/src/node/bundle.ts +88 -0
- package/src/node/cloud-init.ts +51 -0
- package/src/node/d1.ts +44 -0
- package/src/node/deploy-cf.ts +179 -0
- package/src/node/index.ts +5 -0
- package/src/node/panel.ts +21 -0
- package/src/node/serve.ts +125 -0
- package/src/node/storage.ts +51 -0
- package/src/platform/node/env.ts +4 -0
- package/src/platform/node/hooks.ts +19 -0
- package/src/platform/node/log.ts +7 -0
- package/src/platform/node/migrations.ts +5 -0
- package/src/platform/node/photon.ts +1 -0
- package/src/platform/node/sockets.ts +22 -0
- package/src/platform/node/sse.ts +23 -0
- package/src/platform/workers/env.ts +3 -0
- package/src/platform/workers/hooks.ts +2 -0
- package/src/platform/workers/log.ts +1 -0
- package/src/platform/workers/migrations.ts +1 -0
- package/src/platform/workers/photon.ts +1 -0
- package/src/platform/workers/sockets.ts +3 -0
- package/src/platform/workers/sse.ts +1 -0
- package/src/server/api.ts +27 -0
- package/src/server/app.ts +582 -0
- package/src/server/auth-extra.ts +113 -0
- package/src/server/auth-flows.ts +186 -0
- package/src/server/auth-response.ts +111 -0
- package/src/server/auth.ts +187 -0
- package/src/server/backups.ts +234 -0
- package/src/server/batch.ts +123 -0
- package/src/server/bootstrap.ts +71 -0
- package/src/server/collections/auth-option-shape.json +71 -0
- package/src/server/collections/ddl.ts +127 -0
- package/src/server/collections/fields.ts +120 -0
- package/src/server/collections/model.ts +185 -0
- package/src/server/collections/oauth2-providers.json +1 -0
- package/src/server/collections/scaffolds.json +210 -0
- package/src/server/collections/service.ts +392 -0
- package/src/server/collections/system.json +605 -0
- package/src/server/collections/system.ts +19 -0
- package/src/server/collections/validate.ts +239 -0
- package/src/server/crc32.ts +13 -0
- package/src/server/crons.ts +100 -0
- package/src/server/crypto.ts +26 -0
- package/src/server/db.ts +37 -0
- package/src/server/errors.ts +53 -0
- package/src/server/files-api.ts +52 -0
- package/src/server/filter/compile.ts +420 -0
- package/src/server/filter/lexer.ts +107 -0
- package/src/server/filter/parser.ts +49 -0
- package/src/server/hardening.ts +136 -0
- package/src/server/hooks/index.ts +147 -0
- package/src/server/hooks/migrations.ts +58 -0
- package/src/server/hooks/node-async-hooks.d.ts +7 -0
- package/src/server/hooks/record.ts +152 -0
- package/src/server/hooks/runtime.ts +344 -0
- package/src/server/hooks/virtual-migrations.d.ts +4 -0
- package/src/server/hooks/virtual.d.ts +7 -0
- package/src/server/hub.ts +91 -0
- package/src/server/ids.ts +22 -0
- package/src/server/jobs.ts +84 -0
- package/src/server/jwt.ts +61 -0
- package/src/server/logs.ts +144 -0
- package/src/server/mail/index.ts +99 -0
- package/src/server/mail/message.ts +43 -0
- package/src/server/mail/smtp.ts +82 -0
- package/src/server/mail/templates.ts +168 -0
- package/src/server/oauth2/index.ts +198 -0
- package/src/server/oauth2/providers.ts +153 -0
- package/src/server/password.ts +17 -0
- package/src/server/realtime/hub-client.ts +50 -0
- package/src/server/realtime/index.ts +239 -0
- package/src/server/records/expand.ts +129 -0
- package/src/server/records/files.ts +69 -0
- package/src/server/records/json.ts +23 -0
- package/src/server/records/picker.ts +80 -0
- package/src/server/records/service.ts +598 -0
- package/src/server/records/thumbs.ts +148 -0
- package/src/server/records/values.ts +295 -0
- package/src/server/settings-api.ts +104 -0
- package/src/server/settings.ts +215 -0
- package/src/server/sql.ts +61 -0
- package/src/server/static.ts +17 -0
- package/src/server/storage/s3.ts +118 -0
- package/src/server/types.ts +25 -0
- package/src/server/webauthn.ts +168 -0
- package/tsconfig.json +36 -0
- package/tsconfig.node.json +27 -0
- package/types/pb_data.d.ts +24438 -0
- package/vite.config.ts +10 -0
- package/void.json +12 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Realtime (decision d5): PocketBase's SSE protocol on void/sse. Two transports behind the same protocol:
|
|
2
|
+
//
|
|
3
|
+
// - the hub (HUB binding, src/server/hub.ts): every connection holds one hibernatable WebSocket to the instance's
|
|
4
|
+
// Durable Object, record writes publish to it, subscription changes are relayed through it. Push, no polling,
|
|
5
|
+
// nothing shared between instances.
|
|
6
|
+
// - the D1 change feed (no binding: the Bun runtime, a deploy without the hub): every connection runs a poll loop
|
|
7
|
+
// inside its request; the loops in one isolate share the last query result (plain data), about one D1 read per
|
|
8
|
+
// second per isolate, zero when nobody is connected.
|
|
9
|
+
//
|
|
10
|
+
// Workers bind timers and I/O objects to the request that created them, so both the poll loop and the hub socket
|
|
11
|
+
// live inside the SSE request, and a stream is only ever written from its own request.
|
|
12
|
+
import type { Context } from "hono";
|
|
13
|
+
import { eventStream } from "#platform/sse";
|
|
14
|
+
import type { Collection } from "../collections/model";
|
|
15
|
+
import { loadCollections } from "../collections/model";
|
|
16
|
+
import { one, stmt } from "../db";
|
|
17
|
+
import { badRequest, notFound } from "../errors";
|
|
18
|
+
import { nowString, randomString } from "../ids";
|
|
19
|
+
import { findAuthRecordByToken, isSuperuser } from "../auth";
|
|
20
|
+
import { enrich, fetchRecord, recordMatchesRule, type RecordContext } from "../records/service";
|
|
21
|
+
import { rowToValues } from "../records/values";
|
|
22
|
+
import { trigger } from "../hooks/runtime";
|
|
23
|
+
import type { AppEnv, Row } from "../types";
|
|
24
|
+
import { controlClient, hubActive, openHubSocket, sendFilter, type ChangeEvent, type HubMessage } from "./hub-client";
|
|
25
|
+
|
|
26
|
+
interface Subscription { topic: string; collection: string; recordId: string | null; query: Record<string, string>; headers: Record<string, string> }
|
|
27
|
+
interface Change { id: number; collection: string; recordId: string; action: string; data: string | null }
|
|
28
|
+
interface Client {
|
|
29
|
+
id: string;
|
|
30
|
+
cursor: number;
|
|
31
|
+
subs: Subscription[];
|
|
32
|
+
token: string;
|
|
33
|
+
send: (event: string, data: unknown) => Promise<void>;
|
|
34
|
+
closed: boolean;
|
|
35
|
+
hub?: WebSocket | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const clients = new Map<string, Client>();
|
|
39
|
+
const POLL_MS = 1000;
|
|
40
|
+
const CHANGES_LIMIT = 500;
|
|
41
|
+
// last change-feed read in this isolate, shared by every connection loop (data only, never I/O objects)
|
|
42
|
+
let lastRead: { at: number; since: number; changes: Change[] } | null = null;
|
|
43
|
+
|
|
44
|
+
export function parseSubscription(raw: string): Subscription | null {
|
|
45
|
+
const [topicPart, optionsPart] = raw.split("?options=");
|
|
46
|
+
const topic = topicPart ?? "";
|
|
47
|
+
if (topic.startsWith("@")) return { topic: raw, collection: topic, recordId: null, query: {}, headers: {} }; // @oauth2 and friends: messages, not records
|
|
48
|
+
const m = /^([^/]+)\/(.+)$/.exec(topic);
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
let query: Record<string, string> = {}, headers: Record<string, string> = {};
|
|
51
|
+
if (optionsPart) {
|
|
52
|
+
try {
|
|
53
|
+
const o = JSON.parse(decodeURIComponent(optionsPart)) as { query?: Record<string, string>; headers?: Record<string, string> };
|
|
54
|
+
query = o.query ?? {};
|
|
55
|
+
headers = Object.fromEntries(Object.entries(o.headers ?? {}).map(([k, v]) => [k.toLowerCase(), String(v)]));
|
|
56
|
+
} catch { /* ignore malformed options */ }
|
|
57
|
+
}
|
|
58
|
+
return { topic: raw, collection: m[1]!, recordId: m[2] === "*" ? null : m[2]!, query, headers };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
62
|
+
|
|
63
|
+
// GET /api/realtime
|
|
64
|
+
export async function connect(c: Context<AppEnv>): Promise<Response> {
|
|
65
|
+
const env = c.env;
|
|
66
|
+
const clientId = randomString(40);
|
|
67
|
+
const token = c.req.header("Authorization")?.replace(/^bearer /i, "") ?? "";
|
|
68
|
+
const now = nowString();
|
|
69
|
+
await stmt(env.DB, "INSERT INTO `_realtime_clients` (id, subscriptions, token, created, updated) VALUES (?, '[]', ?, ?, ?)", [clientId, token, now, now]).run();
|
|
70
|
+
const useHub = hubActive();
|
|
71
|
+
const max = useHub ? null : await one<{ m: number | null }>(env.DB, "SELECT MAX(id) AS m FROM `_changes`");
|
|
72
|
+
const cursor = max?.m ?? 0;
|
|
73
|
+
return eventStream(
|
|
74
|
+
async (stream) => {
|
|
75
|
+
const client: Client = {
|
|
76
|
+
id: clientId, cursor, subs: [], token, closed: false,
|
|
77
|
+
send: async (event, data) => { await stream.send({ event, data }); },
|
|
78
|
+
};
|
|
79
|
+
clients.set(clientId, client);
|
|
80
|
+
let hubSocket: WebSocket | null = null;
|
|
81
|
+
if (useHub) {
|
|
82
|
+
// the socket belongs to this request, like the stream it feeds; if the hub is unreachable the stream ends
|
|
83
|
+
// and the SDK reconnects, since writers publish there instead of writing feed rows
|
|
84
|
+
try { hubSocket = await openHubSocket(clientId); client.hub = hubSocket; } catch (err) { console.error("voidbase: realtime hub unreachable", err); stream.close(); return; }
|
|
85
|
+
hubSocket.addEventListener("message", (e) => { void onHubMessage(env, client, String(e.data)).catch((err) => { if (!client.closed) console.error("voidbase: realtime hub message failed", err); }); });
|
|
86
|
+
hubSocket.addEventListener("close", () => { if (!client.closed) stream.close(); });
|
|
87
|
+
hubSocket.addEventListener("error", () => { if (!client.closed) stream.close(); });
|
|
88
|
+
// a torn-down request may never reach the cleanup below: close the hub socket on abort as well
|
|
89
|
+
c.req.raw.signal.addEventListener("abort", () => { try { hubSocket?.close(1000, "client gone"); } catch { /* gone */ } });
|
|
90
|
+
}
|
|
91
|
+
await stream.send({ id: clientId, event: "PB_CONNECT", data: { clientId } });
|
|
92
|
+
const loop = useHub ? (async () => {
|
|
93
|
+
// a keepalive the object answers without waking; the race lets the loop end as soon as the stream does
|
|
94
|
+
while (!client.closed) { await Promise.race([sleep(60_000), stream.closed]); if (client.closed) break; try { hubSocket?.send("ping"); } catch { stream.close(); } }
|
|
95
|
+
})() : (async () => {
|
|
96
|
+
while (!client.closed) {
|
|
97
|
+
await sleep(POLL_MS);
|
|
98
|
+
if (client.closed) break;
|
|
99
|
+
try { await pollOne(env, client); } catch (err) { if (!client.closed) console.error("voidbase: realtime poll failed", err); }
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
await stream.closed;
|
|
103
|
+
client.closed = true;
|
|
104
|
+
clients.delete(clientId);
|
|
105
|
+
try { hubSocket?.close(); } catch { /* already closed */ }
|
|
106
|
+
await loop.catch(() => {});
|
|
107
|
+
try { await stmt(env.DB, "DELETE FROM `_realtime_clients` WHERE id = ?", [clientId]).run(); } catch { /* best effort */ }
|
|
108
|
+
},
|
|
109
|
+
{ signal: c.req.raw.signal, keepAlive: { intervalMs: 15000, comment: "" } },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// a frame from the hub: changes to deliver, this client's new subscriptions, or a one-off message
|
|
114
|
+
async function onHubMessage(env: AppEnv["Bindings"], cl: Client, raw: string) {
|
|
115
|
+
if (raw === "pong") return;
|
|
116
|
+
const msg = JSON.parse(raw) as HubMessage;
|
|
117
|
+
if (msg.t === "subs") { applySubscriptions(cl, msg.subscriptions, msg.token); await announceFilter(env, cl); return; }
|
|
118
|
+
if (msg.t === "message") {
|
|
119
|
+
if (!cl.subs.some((s) => s.topic === msg.event)) return;
|
|
120
|
+
await cl.send(msg.event, msg.data ?? {});
|
|
121
|
+
cl.subs = cl.subs.filter((s) => s.topic !== msg.event);
|
|
122
|
+
await stmt(env.DB, "UPDATE `_realtime_clients` SET subscriptions = ?, updated = ? WHERE id = ?", [JSON.stringify(cl.subs.map((s) => s.topic)), nowString(), cl.id]).run();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (msg.t === "changes") {
|
|
126
|
+
const collections = await loadCollections(env.DB);
|
|
127
|
+
for (const ch of msg.changes) { if (cl.closed) return; await dispatch(env, cl, { id: 0, collection: ch.collection, recordId: ch.recordId, action: ch.action, data: ch.data ? JSON.stringify(ch.data) : null }, collections); }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function applySubscriptions(cl: Client, subs: string[], token: string) {
|
|
131
|
+
cl.subs = subs.map(parseSubscription).filter((s): s is Subscription => !!s);
|
|
132
|
+
cl.token = token;
|
|
133
|
+
}
|
|
134
|
+
// tell the hub which collections this connection wants (names; ids are resolved), so it skips the rest
|
|
135
|
+
async function announceFilter(env: AppEnv["Bindings"], cl: Client) {
|
|
136
|
+
const ws = cl.hub; if (!ws) return;
|
|
137
|
+
const collections = await loadCollections(env.DB);
|
|
138
|
+
const names = new Set<string>();
|
|
139
|
+
for (const s of cl.subs) { if (s.collection.startsWith("@")) continue; const col = collections.get(s.collection); names.add(col ? col.name : s.collection); }
|
|
140
|
+
sendFilter(ws, [...names]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// POST /api/realtime {clientId, subscriptions: []}
|
|
144
|
+
export async function setSubscriptions(c: Context<AppEnv>, pre?: { clientId?: string; subscriptions?: string[] }): Promise<Response> {
|
|
145
|
+
let body: { clientId?: string; subscriptions?: string[] } = pre ?? {};
|
|
146
|
+
if (!pre) {
|
|
147
|
+
try {
|
|
148
|
+
const ct = c.req.header("content-type") ?? "";
|
|
149
|
+
body = ct.includes("json") ? await c.req.json() : (Object.fromEntries((await c.req.formData()).entries()) as unknown as typeof body);
|
|
150
|
+
} catch { throw badRequest(); }
|
|
151
|
+
}
|
|
152
|
+
const clientId = String(body.clientId ?? "");
|
|
153
|
+
const subs = Array.isArray(body.subscriptions) ? body.subscriptions.map(String) : [];
|
|
154
|
+
if (!clientId) throw badRequest("An error occurred while validating the submitted data.", { clientId: { code: "validation_required", message: "Cannot be blank." } });
|
|
155
|
+
const row = await one(c.env.DB, "SELECT id FROM `_realtime_clients` WHERE id = ?", [clientId]);
|
|
156
|
+
if (!row) throw notFound("Missing or invalid client id.");
|
|
157
|
+
const token = c.req.header("Authorization")?.replace(/^bearer /i, "") ?? "";
|
|
158
|
+
await stmt(c.env.DB, "UPDATE `_realtime_clients` SET subscriptions = ?, token = ?, updated = ? WHERE id = ?", [JSON.stringify(subs), token, nowString(), clientId]).run();
|
|
159
|
+
const local = clients.get(clientId);
|
|
160
|
+
if (local) { applySubscriptions(local, subs, token); if (local.hub) await announceFilter(c.env, local); }
|
|
161
|
+
else if (hubActive()) await controlClient(clientId, subs, token); // the stream lives in another isolate
|
|
162
|
+
return c.body(null, 204);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// One tick for one connection: refresh its subscriptions (they may have been set through another isolate),
|
|
166
|
+
// read the change feed past its cursor (reusing this isolate's last read when it covers the range) and deliver.
|
|
167
|
+
async function pollOne(env: AppEnv["Bindings"], cl: Client) {
|
|
168
|
+
const db = env.DB;
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const reuse = lastRead && now - lastRead.at < POLL_MS && lastRead.since <= cl.cursor && lastRead.changes.length < CHANGES_LIMIT ? lastRead : null;
|
|
171
|
+
const statements = [stmt(db, "SELECT subscriptions, token FROM `_realtime_clients` WHERE id = ?", [cl.id])];
|
|
172
|
+
if (!reuse) statements.push(stmt(db, "SELECT id, collection, recordId, action, data FROM `_changes` WHERE id > ? ORDER BY id ASC LIMIT ?", [cl.cursor, CHANGES_LIMIT]));
|
|
173
|
+
const results = await db.batch(statements);
|
|
174
|
+
const me = (results[0]?.results?.[0] ?? null) as { subscriptions: string; token: string } | null;
|
|
175
|
+
if (me) {
|
|
176
|
+
cl.subs = (JSON.parse(me.subscriptions || "[]") as string[]).map(parseSubscription).filter((s): s is Subscription => !!s);
|
|
177
|
+
cl.token = me.token;
|
|
178
|
+
}
|
|
179
|
+
let changes: Change[];
|
|
180
|
+
if (reuse) changes = reuse.changes.filter((ch) => ch.id > cl.cursor);
|
|
181
|
+
else {
|
|
182
|
+
changes = (results[1]?.results ?? []) as unknown as Change[];
|
|
183
|
+
lastRead = { at: now, since: cl.cursor, changes };
|
|
184
|
+
}
|
|
185
|
+
if (!changes.length) return;
|
|
186
|
+
const collections = await loadCollections(db);
|
|
187
|
+
for (const ch of changes) {
|
|
188
|
+
if (cl.closed) return;
|
|
189
|
+
cl.cursor = Math.max(cl.cursor, ch.id);
|
|
190
|
+
await dispatch(env, cl, ch, collections);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// one change for one connection: the OAuth2 hand-off, or every matching subscription through the rules
|
|
195
|
+
async function dispatch(env: AppEnv["Bindings"], cl: Client, ch: Change, collections: Map<string, Collection>) {
|
|
196
|
+
const db = env.DB;
|
|
197
|
+
if (ch.collection.startsWith("@")) { // one-off message for a single client (OAuth2 redirect handoff)
|
|
198
|
+
if (ch.recordId === cl.id && cl.subs.some((s) => s.topic === ch.collection)) {
|
|
199
|
+
await cl.send(ch.collection, ch.data ? JSON.parse(ch.data) : {});
|
|
200
|
+
cl.subs = cl.subs.filter((s) => s.topic !== ch.collection);
|
|
201
|
+
await stmt(db, "UPDATE `_realtime_clients` SET subscriptions = ?, updated = ? WHERE id = ?", [JSON.stringify(cl.subs.map((s) => s.topic)), nowString(), cl.id]).run();
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const matching = cl.subs.filter((s) => (s.collection === ch.collection || collections.get(s.collection)?.name === ch.collection) && (s.recordId === null || s.recordId === ch.recordId));
|
|
206
|
+
if (!matching.length) return;
|
|
207
|
+
const collection = collections.get(ch.collection);
|
|
208
|
+
if (!collection) return;
|
|
209
|
+
for (const sub of matching) {
|
|
210
|
+
try { await deliver(db, env, cl, sub, collection, ch, collections); } catch (err) { if (!cl.closed) console.error("voidbase: realtime deliver failed", err); }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function deliver(db: D1Database, bindings: AppEnv["Bindings"], cl: Client, sub: Subscription, collection: Collection, ch: Change, collections: Map<string, Collection>) {
|
|
215
|
+
const token = sub.headers.authorization?.replace(/^bearer /i, "") || cl.token;
|
|
216
|
+
const auth = token ? await findAuthRecordByToken(db, token) : null;
|
|
217
|
+
const ctx: RecordContext = {
|
|
218
|
+
db, storage: bindings.STORAGE, auth, superuser: isSuperuser(auth),
|
|
219
|
+
request: { auth: auth ? { collection: auth.collection, row: auth.row } : null, method: "GET", query: sub.query, headers: sub.headers, body: {}, context: "realtime" },
|
|
220
|
+
collections,
|
|
221
|
+
};
|
|
222
|
+
const rule = sub.recordId === null ? collection.listRule : collection.viewRule;
|
|
223
|
+
let row: Row | null = null;
|
|
224
|
+
if (ch.action === "delete") {
|
|
225
|
+
if (!ch.data) return;
|
|
226
|
+
row = JSON.parse(ch.data) as Row;
|
|
227
|
+
if (!ctx.superuser) {
|
|
228
|
+
if (rule === null) return;
|
|
229
|
+
if (rule.trim() !== "" && !(await recordMatchesRule(ctx, collection, rule, rowToValues(collection, row)))) return;
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
try { row = await fetchRecord(ctx, collection, ch.recordId, rule); } catch { row = null; }
|
|
233
|
+
if (!row) return; // gone, or not visible to this subscriber
|
|
234
|
+
}
|
|
235
|
+
const [record] = await enrich(ctx, collection, [row], { expand: sub.query.expand, fields: sub.query.fields });
|
|
236
|
+
if (cl.closed) return;
|
|
237
|
+
const ev = { app: undefined as unknown, client: { id: cl.id, subscriptions: cl.subs.map((s) => s.topic) }, message: { name: sub.topic, data: { action: ch.action, record } }, next: async () => undefined as unknown };
|
|
238
|
+
await trigger("onRealtimeMessageSend", ev, null, async () => { if (!cl.closed) await cl.send(ev.message.name, ev.message.data); });
|
|
239
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Relation expansion (core/record_query_expand.go): direct and nested paths, back-relations `coll_via_field`,
|
|
2
|
+
// batched fetches with the target collection's view rule applied, depth capped at 6.
|
|
3
|
+
import { isMultiple, type Field } from "../collections/fields";
|
|
4
|
+
import type { Collection } from "../collections/model";
|
|
5
|
+
import { all, ident } from "../db";
|
|
6
|
+
import { compileFilter, renderJoin, type RequestInfo } from "../filter/compile";
|
|
7
|
+
import type { AuthRecord, Row } from "../types";
|
|
8
|
+
import { recordToJSON } from "./json";
|
|
9
|
+
import { fromColumn } from "./values";
|
|
10
|
+
|
|
11
|
+
export interface ExpandContext {
|
|
12
|
+
db: D1Database;
|
|
13
|
+
collections: Map<string, Collection>;
|
|
14
|
+
auth: AuthRecord | null;
|
|
15
|
+
superuser: boolean;
|
|
16
|
+
request: RequestInfo;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MAX_DEPTH = 6;
|
|
20
|
+
const VIA = /^(\w+)_via_(\w+)$/;
|
|
21
|
+
|
|
22
|
+
// Returns per-record expand objects (keyed by record id). Records without any resolvable expand get no entry
|
|
23
|
+
// unless a valid relation path was requested, in which case they get {} (PocketBase keeps the key).
|
|
24
|
+
export async function expandRecords(ctx: ExpandContext, c: Collection, rows: Row[], expands: string[]): Promise<Map<string, Record<string, unknown>>> {
|
|
25
|
+
const result = new Map<string, Record<string, unknown>>();
|
|
26
|
+
const paths = [...new Set(expands.map((s) => s.trim()).filter(Boolean))];
|
|
27
|
+
for (const path of paths) await expandPath(ctx, c, rows, path, 1, result);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function expandPath(ctx: ExpandContext, c: Collection, rows: Row[], path: string, depth: number, result: Map<string, Record<string, unknown>>): Promise<void> {
|
|
32
|
+
if (depth > MAX_DEPTH || rows.length === 0) return;
|
|
33
|
+
const [head, ...restParts] = path.split(".");
|
|
34
|
+
const rest = restParts.join(".");
|
|
35
|
+
const fields = c.fields as Field[];
|
|
36
|
+
let related: Collection | null = null;
|
|
37
|
+
let byRow = new Map<string, Row[]>(); // base row id -> related rows
|
|
38
|
+
let single = false;
|
|
39
|
+
const relField = fields.find((f) => f.name === head && f.type === "relation");
|
|
40
|
+
if (relField) {
|
|
41
|
+
related = ctx.collections.get(String(relField.collectionId)) ?? null;
|
|
42
|
+
if (!related) return;
|
|
43
|
+
single = !isMultiple(relField);
|
|
44
|
+
const idsByRow = new Map<string, string[]>();
|
|
45
|
+
const allIds = new Set<string>();
|
|
46
|
+
for (const r of rows) {
|
|
47
|
+
const v = fromColumn(relField, r[relField.name]);
|
|
48
|
+
const ids = Array.isArray(v) ? v : v ? [String(v)] : [];
|
|
49
|
+
idsByRow.set(String(r.id), ids);
|
|
50
|
+
ids.forEach((id) => allIds.add(id));
|
|
51
|
+
}
|
|
52
|
+
const fetched = await fetchAllowed(ctx, related, [...allIds]);
|
|
53
|
+
for (const [rid, ids] of idsByRow) byRow.set(rid, ids.map((id) => fetched.get(id)).filter((x): x is Row => !!x));
|
|
54
|
+
} else {
|
|
55
|
+
const via = VIA.exec(head!);
|
|
56
|
+
if (!via) return; // unknown expand: ignored, as PocketBase does
|
|
57
|
+
related = ctx.collections.get(via[1]!) ?? null;
|
|
58
|
+
const backField = related && (related.fields as Field[]).find((f) => f.name === via[2] && f.type === "relation" && f.collectionId === c.id);
|
|
59
|
+
if (!related || !backField) return;
|
|
60
|
+
single = related.indexes.some((idx) => /UNIQUE/i.test(idx) && new RegExp("\\(\\s*[`\"']?" + backField.name + "[`\"']?\\s*\\)").test(idx));
|
|
61
|
+
const ids = rows.map((r) => String(r.id));
|
|
62
|
+
const fetched = await fetchBackRelated(ctx, related, backField, ids);
|
|
63
|
+
for (const r of rows) byRow.set(String(r.id), []);
|
|
64
|
+
for (const rel of fetched) {
|
|
65
|
+
const v = fromColumn(backField, rel[backField.name]);
|
|
66
|
+
const targets = Array.isArray(v) ? v : v ? [String(v)] : [];
|
|
67
|
+
for (const t of targets) byRow.get(t)?.push(rel);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// nested expands on the related rows
|
|
71
|
+
const nested = new Map<string, Record<string, unknown>>();
|
|
72
|
+
const relatedRows = [...new Map([...byRow.values()].flat().map((r) => [String(r.id), r])).values()];
|
|
73
|
+
if (rest) await expandPath(ctx, related, relatedRows, rest, depth + 1, nested);
|
|
74
|
+
for (const r of rows) {
|
|
75
|
+
const rid = String(r.id);
|
|
76
|
+
const items = byRow.get(rid) ?? [];
|
|
77
|
+
const entry = result.get(rid) ?? {};
|
|
78
|
+
const toJSON = (x: Row) => recordToJSON(related!, x, { auth: ctx.auth, own: ctx.auth?.collection.id === related!.id && ctx.auth.row.id === x.id, expand: nested.get(String(x.id)) });
|
|
79
|
+
if (single) { if (items[0]) entry[head!] = toJSON(items[0]); }
|
|
80
|
+
else if (items.length) entry[head!] = items.map(toJSON);
|
|
81
|
+
result.set(rid, entry);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fetch records by id through the collection's view rule (superusers bypass it).
|
|
86
|
+
async function fetchAllowed(ctx: ExpandContext, c: Collection, ids: string[]): Promise<Map<string, Row>> {
|
|
87
|
+
const out = new Map<string, Row>();
|
|
88
|
+
if (ids.length === 0) return out;
|
|
89
|
+
if (!ctx.superuser && c.viewRule === null) return out;
|
|
90
|
+
let ruleSql = "";
|
|
91
|
+
let ruleParams: unknown[] = [];
|
|
92
|
+
let joins = "";
|
|
93
|
+
if (!ctx.superuser && c.viewRule) {
|
|
94
|
+
const compiled = compileFilter(c.viewRule, { base: c, collections: ctx.collections, request: { ...ctx.request, context: "expand" }, allowHiddenFields: true });
|
|
95
|
+
ruleSql = ` AND (${compiled.where})`;
|
|
96
|
+
ruleParams = compiled.params;
|
|
97
|
+
joins = compiled.joins.map(renderJoin).join(" ");
|
|
98
|
+
}
|
|
99
|
+
for (let i = 0; i < ids.length; i += 80) {
|
|
100
|
+
const chunk = ids.slice(i, i + 80);
|
|
101
|
+
const rows = await all(ctx.db, `SELECT DISTINCT ${ident(c.name)}.* FROM ${ident(c.name)} ${joins} WHERE ${ident(c.name)}.id IN (${chunk.map(() => "?").join(",")})${ruleSql}`, [...chunk, ...ruleParams]);
|
|
102
|
+
for (const r of rows) out.set(String(r.id), r);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function fetchBackRelated(ctx: ExpandContext, c: Collection, backField: Field, ids: string[]): Promise<Row[]> {
|
|
108
|
+
if (ids.length === 0) return [];
|
|
109
|
+
if (!ctx.superuser && c.viewRule === null) return [];
|
|
110
|
+
let ruleSql = "";
|
|
111
|
+
let ruleParams: unknown[] = [];
|
|
112
|
+
let joins = "";
|
|
113
|
+
if (!ctx.superuser && c.viewRule) {
|
|
114
|
+
const compiled = compileFilter(c.viewRule, { base: c, collections: ctx.collections, request: { ...ctx.request, context: "expand" }, allowHiddenFields: true });
|
|
115
|
+
ruleSql = ` AND (${compiled.where})`;
|
|
116
|
+
ruleParams = compiled.params;
|
|
117
|
+
joins = compiled.joins.map(renderJoin).join(" ");
|
|
118
|
+
}
|
|
119
|
+
const out: Row[] = [];
|
|
120
|
+
for (let i = 0; i < ids.length; i += 80) {
|
|
121
|
+
const chunk = ids.slice(i, i + 80);
|
|
122
|
+
const col = `${ident(c.name)}.${ident(backField.name)}`;
|
|
123
|
+
const match = isMultiple(backField)
|
|
124
|
+
? `EXISTS (SELECT 1 FROM json_each(CASE WHEN json_valid(${col}) THEN ${col} ELSE json_array(${col}) END) WHERE value IN (${chunk.map(() => "?").join(",")}))`
|
|
125
|
+
: `${col} IN (${chunk.map(() => "?").join(",")})`;
|
|
126
|
+
out.push(...(await all(ctx.db, `SELECT DISTINCT ${ident(c.name)}.* FROM ${ident(c.name)} ${joins} WHERE ${match}${ruleSql}`, [...chunk, ...ruleParams])));
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// File naming, content sniffing, and R2 storage keyed {collectionId}/{recordId}/{filename}.
|
|
2
|
+
import { randomWithAlphabet } from "../ids";
|
|
3
|
+
import type { Upload } from "./values";
|
|
4
|
+
|
|
5
|
+
const EXT_INVALID = /[^\w.*\-+=#]+/g;
|
|
6
|
+
|
|
7
|
+
// tools/filesystem.normalizeName: snakecase(name) + "_" + random10 + ext (random part always present)
|
|
8
|
+
export function normalizeFilename(original: string, detectedExt: string): string {
|
|
9
|
+
let name = original.length > 300 ? original.slice(-300) : original;
|
|
10
|
+
const dot = name.lastIndexOf(".");
|
|
11
|
+
const originalExt = dot >= 0 ? name.slice(dot) : "";
|
|
12
|
+
let ext = "." + originalExt.replace(EXT_INVALID, "").replace(/^\.+|\.+$/g, "");
|
|
13
|
+
if (ext === ".") ext = detectedExt || "";
|
|
14
|
+
if (ext.length > 20) ext = "." + ext.slice(-20).replace(/^\.+/, "");
|
|
15
|
+
let clean = snakecase((originalExt ? name.slice(0, -originalExt.length) : name).replace(/^\.+|\.+$/g, ""));
|
|
16
|
+
if (clean.length < 3) clean += randomWithAlphabet(10, "abcdefghijklmnopqrstuvwxyz0123456789");
|
|
17
|
+
else if (clean.length > 100) clean = clean.slice(0, 100);
|
|
18
|
+
return `${clean}_${randomWithAlphabet(10, "abcdefghijklmnopqrstuvwxyz0123456789")}${ext.toLowerCase()}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function snakecase(s: string): string {
|
|
22
|
+
return s.split(/[^\p{L}\p{N}]+/u).filter(Boolean).map((w) => w.replace(/([a-z0-9])([A-Z])/g, "$1_$2")).join("_").toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Minimal content sniffing for the common cases; falls back to the client's declared type.
|
|
26
|
+
export function sniffMime(bytes: Uint8Array, declared: string, name: string): { type: string; ext: string } {
|
|
27
|
+
const b = bytes;
|
|
28
|
+
const startsWith = (...sig: number[]) => sig.every((x, i) => b[i] === x);
|
|
29
|
+
if (startsWith(0x89, 0x50, 0x4e, 0x47)) return { type: "image/png", ext: ".png" };
|
|
30
|
+
if (startsWith(0xff, 0xd8, 0xff)) return { type: "image/jpeg", ext: ".jpg" };
|
|
31
|
+
if (startsWith(0x47, 0x49, 0x46, 0x38)) return { type: "image/gif", ext: ".gif" };
|
|
32
|
+
if (startsWith(0x52, 0x49, 0x46, 0x46) && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return { type: "image/webp", ext: ".webp" };
|
|
33
|
+
if (startsWith(0x25, 0x50, 0x44, 0x46)) return { type: "application/pdf", ext: ".pdf" };
|
|
34
|
+
if (startsWith(0x50, 0x4b, 0x03, 0x04)) return { type: "application/zip", ext: ".zip" };
|
|
35
|
+
const head = new TextDecoder().decode(b.slice(0, 512)).trimStart();
|
|
36
|
+
if (/^<svg[\s>]/i.test(head) || (/^<\?xml/i.test(head) && /<svg/i.test(head))) return { type: "image/svg+xml", ext: ".svg" };
|
|
37
|
+
if (/^\s*[{[]/.test(head)) { try { JSON.parse(new TextDecoder().decode(b)); return { type: "application/json", ext: ".json" }; } catch { /* not json */ } }
|
|
38
|
+
const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")).toLowerCase() : "";
|
|
39
|
+
// PocketBase detects the type from the bytes (gabriel-vasile/mimetype) and ignores the declared one
|
|
40
|
+
void declared;
|
|
41
|
+
const isText = b.slice(0, 512).every((x) => x === 9 || x === 10 || x === 13 || (x >= 32 && x < 127) || x >= 128);
|
|
42
|
+
return isText ? { type: "text/plain; charset=utf-8", ext: ext || ".txt" } : { type: "application/octet-stream", ext };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const fileKey = (collectionId: string, recordId: string, filename: string) => `${collectionId}/${recordId}/${filename}`;
|
|
46
|
+
|
|
47
|
+
export async function putUpload(storage: R2Bucket, collectionId: string, recordId: string, up: Upload): Promise<void> {
|
|
48
|
+
await storage.put(fileKey(collectionId, recordId, up.name), up.bytes, { httpMetadata: { contentType: up.type } });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function deleteFiles(storage: R2Bucket, collectionId: string, recordId: string, names: string[]): Promise<void> {
|
|
52
|
+
if (names.length === 0) return;
|
|
53
|
+
await storage.delete(names.map((n) => fileKey(collectionId, recordId, n)));
|
|
54
|
+
// cached thumbnails live under thumbs_{filename}/
|
|
55
|
+
for (const n of names) await deletePrefix(storage, `${collectionId}/${recordId}/thumbs_${n}/`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function deleteAllRecordFiles(storage: R2Bucket, collectionId: string, recordId: string): Promise<void> {
|
|
59
|
+
await deletePrefix(storage, `${collectionId}/${recordId}/`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function deletePrefix(storage: R2Bucket, prefix: string): Promise<void> {
|
|
63
|
+
let cursor: string | undefined;
|
|
64
|
+
do {
|
|
65
|
+
const listed = await storage.list({ prefix, cursor });
|
|
66
|
+
if (listed.objects.length) await storage.delete(listed.objects.map((o) => o.key));
|
|
67
|
+
cursor = listed.truncated ? listed.cursor : undefined;
|
|
68
|
+
} while (cursor);
|
|
69
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isMultiple, type Field } from "../collections/fields";
|
|
2
|
+
import type { Collection } from "../collections/model";
|
|
3
|
+
import type { AuthRecord, Row } from "../types";
|
|
4
|
+
import { fromColumn } from "./values";
|
|
5
|
+
|
|
6
|
+
export interface JSONOptions { auth?: AuthRecord | null; own?: boolean; expand?: Record<string, unknown> }
|
|
7
|
+
|
|
8
|
+
// The record JSON PocketBase returns: hidden fields dropped, password fields always "", collectionId/Name added,
|
|
9
|
+
// keys sorted, email hidden unless emailVisibility, own record, or superuser; `expand` attached when present.
|
|
10
|
+
export function recordToJSON(c: Collection, row: Row, opts: JSONOptions = {}): Record<string, unknown> {
|
|
11
|
+
const out: Record<string, unknown> = { collectionId: c.id, collectionName: c.name };
|
|
12
|
+
const superuser = opts.auth?.collection.name === "_superusers";
|
|
13
|
+
for (const f of c.fields as Field[]) {
|
|
14
|
+
if (f.hidden) continue;
|
|
15
|
+
out[f.name] = f.type === "password" ? "" : fromColumn(f, row[f.name]);
|
|
16
|
+
if (f.type === "relation" || f.type === "file" || f.type === "select") {
|
|
17
|
+
if (!isMultiple(f) && Array.isArray(out[f.name])) out[f.name] = (out[f.name] as string[])[0] ?? "";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (c.type === "auth" && !superuser && !opts.own && !row.emailVisibility) delete out.email;
|
|
21
|
+
if (opts.expand) out.expand = opts.expand;
|
|
22
|
+
return Object.fromEntries(Object.entries(out).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
23
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The `fields` query param: comma separated paths with optional modifiers (`body:excerpt(200,true)`), `*` wildcard.
|
|
2
|
+
interface Node { children: Map<string, Node>; modifier?: (v: unknown) => unknown; leaf: boolean }
|
|
3
|
+
|
|
4
|
+
export function parseFields(raw: string): Node | null {
|
|
5
|
+
const root: Node = { children: new Map(), leaf: false };
|
|
6
|
+
const parts = splitTopLevel(raw).map((s) => s.trim()).filter(Boolean);
|
|
7
|
+
if (!parts.length) return null;
|
|
8
|
+
for (const part of parts) {
|
|
9
|
+
const m = /^([^:]+)(?::(\w+)\(([^)]*)\))?$/.exec(part);
|
|
10
|
+
if (!m) throw new Error(`invalid fields expression "${part}"`);
|
|
11
|
+
const path = m[1]!.split(".").map((s) => s.trim()).filter(Boolean);
|
|
12
|
+
let node = root;
|
|
13
|
+
for (const seg of path) {
|
|
14
|
+
let next = node.children.get(seg);
|
|
15
|
+
if (!next) { next = { children: new Map(), leaf: false }; node.children.set(seg, next); }
|
|
16
|
+
node = next;
|
|
17
|
+
}
|
|
18
|
+
node.leaf = true;
|
|
19
|
+
if (m[2] === "excerpt") {
|
|
20
|
+
const args = (m[3] ?? "").split(",").map((s) => s.trim());
|
|
21
|
+
const max = Number(args[0]) || 0;
|
|
22
|
+
const ellipsis = args[1] === "true";
|
|
23
|
+
node.modifier = (v) => (typeof v === "string" ? excerpt(v, max, ellipsis) : v);
|
|
24
|
+
} else if (m[2]) throw new Error(`unknown fields modifier "${m[2]}"`);
|
|
25
|
+
}
|
|
26
|
+
return root;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function pick(data: unknown, node: Node | null): unknown {
|
|
30
|
+
if (!node) return data;
|
|
31
|
+
if (Array.isArray(data)) return data.map((d) => pick(d, node));
|
|
32
|
+
if (!data || typeof data !== "object") return data;
|
|
33
|
+
const src = data as Record<string, unknown>;
|
|
34
|
+
const out: Record<string, unknown> = {};
|
|
35
|
+
const star = node.children.get("*");
|
|
36
|
+
// tools/picker decodes into map[string]any, so Go serializes the picked object with sorted keys
|
|
37
|
+
for (const [k, v] of Object.entries(src).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
38
|
+
const child = node.children.get(k) ?? star;
|
|
39
|
+
if (!child) continue;
|
|
40
|
+
const value = child.children.size ? pick(v, child) : sortDeep(v);
|
|
41
|
+
out[k] = child.modifier ? child.modifier(value) : value;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// the whole picked document went through map[string]any in Go, so nested objects come back key-sorted too
|
|
47
|
+
function sortDeep(v: unknown): unknown {
|
|
48
|
+
if (Array.isArray(v)) return v.map(sortDeep);
|
|
49
|
+
if (!v || typeof v !== "object") return v;
|
|
50
|
+
return Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([k, x]) => [k, sortDeep(x)]));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const INLINE = new Set(["a", "abbr", "acronym", "b", "bdo", "big", "br", "button", "cite", "code", "dfn", "em", "i", "img", "input", "kbd", "label", "map", "object", "output", "q", "samp", "select", "small", "span", "strong", "sub", "sup", "textarea", "time", "tt", "u", "var", "video"]);
|
|
54
|
+
|
|
55
|
+
// tools/picker/excerpt_modifier.go: strip tags (block tags become spaces), collapse whitespace, cut to max, optional "...".
|
|
56
|
+
export function excerpt(html: string, max: number, ellipsis: boolean): string {
|
|
57
|
+
let text = html.replace(/<(script|style|head|title|template)[\s\S]*?<\/\1>/gi, "");
|
|
58
|
+
text = text.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g, (_m, tag: string) => (INLINE.has(tag.toLowerCase()) ? "" : " "));
|
|
59
|
+
text = text.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
60
|
+
text = text.replace(/\s+/g, " ").trim();
|
|
61
|
+
if (max <= 0 || [...text].length <= max) return text;
|
|
62
|
+
let cut = [...text].slice(0, max).join("");
|
|
63
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
64
|
+
if (lastSpace > 0) cut = cut.slice(0, lastSpace);
|
|
65
|
+
cut = cut.trimEnd();
|
|
66
|
+
return ellipsis ? cut + "..." : cut;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function splitTopLevel(s: string): string[] {
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
let depth = 0, cur = "";
|
|
72
|
+
for (const ch of s) {
|
|
73
|
+
if (ch === "(") depth++;
|
|
74
|
+
if (ch === ")") depth--;
|
|
75
|
+
if (ch === "," && depth === 0) { out.push(cur); cur = ""; continue; }
|
|
76
|
+
cur += ch;
|
|
77
|
+
}
|
|
78
|
+
out.push(cur);
|
|
79
|
+
return out;
|
|
80
|
+
}
|