emdash-multisite 0.0.0-stage → 0.7.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/LICENSE +21 -0
- package/README.md +681 -2
- package/SETUP.md +205 -0
- package/bin/setup.mjs +83 -0
- package/package.json +91 -4
- package/src/abuse.ts +65 -0
- package/src/auth-links.ts +21 -0
- package/src/auth-ui.ts +390 -0
- package/src/backup.ts +137 -0
- package/src/bootstrap.ts +51 -0
- package/src/clone-run.ts +57 -0
- package/src/clone.ts +77 -0
- package/src/config.ts +17 -0
- package/src/console-page.ts +318 -0
- package/src/console.ts +141 -0
- package/src/db/d1.ts +9 -0
- package/src/db/sqlite.ts +6 -0
- package/src/demo.ts +23 -0
- package/src/dialect.ts +109 -0
- package/src/edge-cache.ts +249 -0
- package/src/fleet-actions.ts +44 -0
- package/src/fleet-blocks.ts +115 -0
- package/src/fleet.ts +151 -0
- package/src/handoff.ts +58 -0
- package/src/index.ts +35 -0
- package/src/log.ts +6 -0
- package/src/media-bucket.ts +44 -0
- package/src/middleware.ts +607 -0
- package/src/orgs.ts +343 -0
- package/src/passwords.ts +60 -0
- package/src/platform-env.ts +10 -0
- package/src/plugin-entry.ts +145 -0
- package/src/plugin.ts +26 -0
- package/src/provision.ts +17 -0
- package/src/purge.ts +105 -0
- package/src/registry.ts +421 -0
- package/src/rewrite.ts +199 -0
- package/src/rules.ts +521 -0
- package/src/runtime.ts +69 -0
- package/src/scheduled.ts +107 -0
- package/src/scope.ts +178 -0
- package/src/sites.ts +97 -0
- package/src/slots.ts +38 -0
- package/src/storage.ts +57 -0
- package/src/vite.ts +256 -0
- package/templates/astro.config.snippet.mjs +42 -0
- package/templates/worker.ts +61 -0
- package/templates/wrangler.snippet.jsonc +26 -0
package/src/rewrite.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-site table naming, applied to the FINAL compiled SQL.
|
|
3
|
+
*
|
|
4
|
+
* One site (the "primary") keeps its original, unprefixed table names, so an
|
|
5
|
+
* existing single-site install is untouched. Every other site gets the same
|
|
6
|
+
* schema under `<table>$<site>` inside the same database.
|
|
7
|
+
*
|
|
8
|
+
* Working on SQL text (not the Kysely AST) is deliberate: core's builder
|
|
9
|
+
* queries, `sql.ref(tableName)` and its ~138 literal raw statements all reach
|
|
10
|
+
* the driver as text, so one mechanism covers all of them. `$` is the marker
|
|
11
|
+
* because a collection slug may legally contain `__` but never `$`.
|
|
12
|
+
*
|
|
13
|
+
* Known ceiling: rewriting is positional (table follows FROM/JOIN/INTO/...,
|
|
14
|
+
* or is a `name.` qualifier). A comma-joined second table (`FROM a, b`) or an
|
|
15
|
+
* alias that reuses a core table name is not rewritten; it fails loudly with
|
|
16
|
+
* "no such table/column" rather than reading another site's data.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// DNS-label shaped: lowercase letters, digits and inner hyphens, 1-40 chars.
|
|
20
|
+
const SITE_ID = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
|
21
|
+
export const isSiteId = (s: string): boolean => SITE_ID.test(s);
|
|
22
|
+
|
|
23
|
+
/** Core tables that do not follow the ec_/_emdash_/_plugin_ naming rule. */
|
|
24
|
+
const UNPREFIXED_CORE_TABLES = new Set([
|
|
25
|
+
"allowed_domains",
|
|
26
|
+
"audit_logs",
|
|
27
|
+
"auth_challenges",
|
|
28
|
+
"auth_tokens",
|
|
29
|
+
"content_taxonomies",
|
|
30
|
+
"credentials",
|
|
31
|
+
"media",
|
|
32
|
+
"media_folders",
|
|
33
|
+
"oauth_accounts",
|
|
34
|
+
"options",
|
|
35
|
+
"revisions",
|
|
36
|
+
"taxonomies",
|
|
37
|
+
"users",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** Is this (unsuffixed) table name one that belongs to a single site? */
|
|
41
|
+
export function isSiteTable(name: string): boolean {
|
|
42
|
+
const n = name.toLowerCase();
|
|
43
|
+
if (n.includes("$") || n.startsWith("sqlite_") || n.startsWith("_cf_")) return false;
|
|
44
|
+
if (/^(ec_|_emdash_|_plugin_)/.test(n)) return true;
|
|
45
|
+
// migrations rebuild tables as <t>_new / <t>_old, then rename
|
|
46
|
+
return UNPREFIXED_CORE_TABLES.has(n.replace(/_(new|old)$/, ""));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const suffixFor = (site: string, primary: string): string =>
|
|
50
|
+
site === primary ? "" : `$${site}`;
|
|
51
|
+
|
|
52
|
+
/** Quote a (possibly suffixed) identifier. */
|
|
53
|
+
const q = (name: string): string => `"${name.replaceAll('"', '""')}"`;
|
|
54
|
+
/**
|
|
55
|
+
* A rewritten identifier keeps the quoting it had: "x" stays "x$s", but a bare x becomes `x$s`.
|
|
56
|
+
* translateRows undoes the suffix on stored DDL and needs this to give back the original text
|
|
57
|
+
* (core compares a trigger's stored DDL with what it would generate).
|
|
58
|
+
*/
|
|
59
|
+
const qLike = (name: string, wasQuoted: boolean): string => (wasQuoted ? q(name) : `\`${name}\``);
|
|
60
|
+
|
|
61
|
+
const TOKEN =
|
|
62
|
+
/\s+|'(?:[^']|'')*'|"(?:[^"]|"")*"|`[^`]*`|--[^\n]*|\/\*[\s\S]*?\*\/|[A-Za-z_][A-Za-z0-9_$]*|\d+(?:\.\d+)?|[\s\S]/gy;
|
|
63
|
+
|
|
64
|
+
/** Keywords after which the next identifier is a table name. */
|
|
65
|
+
const TABLE_CONTEXT = new Set(["FROM", "JOIN", "INTO", "UPDATE", "TABLE", "REFERENCES", "EXISTS", "TO"]);
|
|
66
|
+
|
|
67
|
+
const PRAGMA_TABLE_FN =
|
|
68
|
+
/\b((?:pragma_)?(?:table_info|table_xinfo|index_list|foreign_key_list))(\s*\(\s*)(['"`]?)([A-Za-z_][A-Za-z0-9_]*)\3/gi;
|
|
69
|
+
const PRAGMA_INDEX_FN =
|
|
70
|
+
/\b((?:pragma_)?(?:index_info|index_xinfo))(\s*\(\s*)(['"`]?)([A-Za-z_][A-Za-z0-9_]*)\3/gi;
|
|
71
|
+
|
|
72
|
+
function rewritePragmas(sql: string, suffix: string): string {
|
|
73
|
+
const out = (name: string, quote: string) => (quote === "'" ? `'${name}'` : q(name));
|
|
74
|
+
return sql
|
|
75
|
+
.replace(PRAGMA_TABLE_FN, (m, fn: string, open: string, quote: string, name: string) =>
|
|
76
|
+
isSiteTable(name) ? `${fn}${open}${out(name + suffix, quote)}` : m,
|
|
77
|
+
)
|
|
78
|
+
.replace(PRAGMA_INDEX_FN, (m, fn: string, open: string, quote: string, name: string) =>
|
|
79
|
+
name.includes("$") ? m : `${fn}${open}${out(name + suffix, quote)}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* FTS5 gives a virtual table a hidden column with the table's own name. It is
|
|
85
|
+
* what `WHERE "fts" MATCH ?` filters on and what `bm25("fts")`, `snippet("fts", ...)`
|
|
86
|
+
* and `highlight("fts", ...)` take, so it must follow the renamed table.
|
|
87
|
+
*/
|
|
88
|
+
function isFtsColumn(toks: string[], i: number, next: string | undefined): boolean {
|
|
89
|
+
if (next?.toUpperCase() === "MATCH") return true;
|
|
90
|
+
let j = i - 1;
|
|
91
|
+
while (j >= 0 && /^\s+$/.test(toks[j]!)) j--;
|
|
92
|
+
if (toks[j] !== "(") return false;
|
|
93
|
+
j--;
|
|
94
|
+
while (j >= 0 && /^\s+$/.test(toks[j]!)) j--;
|
|
95
|
+
return /^(?:bm25|snippet|highlight)$/i.test(toks[j] ?? "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Rewrite every table/index/trigger identifier in `sql` for a non-primary site. */
|
|
99
|
+
export function rewriteSql(sql: string, suffix: string): string {
|
|
100
|
+
if (!suffix) return sql;
|
|
101
|
+
const head = /^\s*(?:CREATE|DROP)\s+(?:TEMP(?:ORARY)?\s+)?(?:UNIQUE\s+)?(INDEX|TRIGGER)\b/i.exec(sql);
|
|
102
|
+
let objectNamePending = head !== null;
|
|
103
|
+
// In CREATE TRIGGER the table follows ON (`AFTER INSERT ON t WHEN ...`); in
|
|
104
|
+
// CREATE INDEX it follows ON too but is always followed by `(`.
|
|
105
|
+
const isTrigger = head?.[1]?.toUpperCase() === "TRIGGER";
|
|
106
|
+
const toks: string[] = [];
|
|
107
|
+
for (const m of rewritePragmas(sql, suffix).matchAll(TOKEN)) toks.push(m[0]);
|
|
108
|
+
|
|
109
|
+
const significant = (i: number, step: 1 | -1): string | undefined => {
|
|
110
|
+
for (let j = i + step; j >= 0 && j < toks.length; j += step) {
|
|
111
|
+
const t = toks[j]!;
|
|
112
|
+
if (!/^\s+$/.test(t) && !t.startsWith("--") && !t.startsWith("/*")) return t;
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < toks.length; i++) {
|
|
118
|
+
const tok = toks[i]!;
|
|
119
|
+
const quoted = tok[0] === '"' || tok[0] === "`";
|
|
120
|
+
const isWord = /^[A-Za-z_]/.test(tok);
|
|
121
|
+
if (!quoted && !isWord) continue;
|
|
122
|
+
const name = quoted ? tok.slice(1, -1).replaceAll('""', '"') : tok;
|
|
123
|
+
const prev = significant(i, -1);
|
|
124
|
+
const next = significant(i, 1);
|
|
125
|
+
|
|
126
|
+
// CREATE/DROP INDEX|TRIGGER [IF [NOT] EXISTS] <name>: names are global in SQLite.
|
|
127
|
+
if (objectNamePending && !quoted && /^(INDEX|TRIGGER|UNIQUE|IF|NOT|EXISTS|CREATE|DROP)$/i.test(tok)) continue;
|
|
128
|
+
if (objectNamePending) {
|
|
129
|
+
objectNamePending = false;
|
|
130
|
+
if (!name.includes("$")) toks[i] = qLike(name + suffix, quoted);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!isSiteTable(name)) continue;
|
|
135
|
+
const upPrev = prev && /^[A-Za-z_]+$/.test(prev) ? prev.toUpperCase() : "";
|
|
136
|
+
const inTableSlot =
|
|
137
|
+
(upPrev !== "" && TABLE_CONTEXT.has(upPrev)) ||
|
|
138
|
+
(upPrev === "ON" && (next === "(" || (isTrigger && next !== "."))) ||
|
|
139
|
+
(next === "." && prev !== ".") ||
|
|
140
|
+
isFtsColumn(toks, i, next);
|
|
141
|
+
if (inTableSlot) toks[i] = qLike(name + suffix, quoted);
|
|
142
|
+
}
|
|
143
|
+
return toks.join("");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const INTROSPECTION = /sqlite_(?:master|schema)|\bpragma\b|pragma_/i;
|
|
147
|
+
const INDEX_PRAGMA = /index_info|index_xinfo/i;
|
|
148
|
+
const TYPE_WORDS = new Set(["table", "index", "view", "trigger"]);
|
|
149
|
+
|
|
150
|
+
export const isIntrospection = (sql: string): boolean => INTROSPECTION.test(sql);
|
|
151
|
+
|
|
152
|
+
/** Bound parameters that are object names in an introspection query get the suffix too. */
|
|
153
|
+
export function rewriteParams(sql: string, params: readonly unknown[], suffix: string): readonly unknown[] {
|
|
154
|
+
if (!suffix || !isIntrospection(sql)) return params;
|
|
155
|
+
const anyName = /sqlite_(?:master|schema)/i.test(sql) || INDEX_PRAGMA.test(sql);
|
|
156
|
+
return params.map((p) => {
|
|
157
|
+
if (typeof p !== "string" || p.includes("$") || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(p)) return p;
|
|
158
|
+
if (TYPE_WORDS.has(p.toLowerCase())) return p;
|
|
159
|
+
// A trailing "_" marks a name prefix (core matches its trigger names with substr(name,1,10) = 'emdash_mu_'); suffixing it matches nothing.
|
|
160
|
+
if (p.endsWith("_")) return p;
|
|
161
|
+
return anyName || isSiteTable(p) ? p + suffix : p;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type Row = Record<string, unknown>;
|
|
166
|
+
const NAME_KEYS = ["name", "tbl_name", "table"] as const;
|
|
167
|
+
|
|
168
|
+
/** Make an introspection result look like this site's own unprefixed database. */
|
|
169
|
+
export function translateRows<R>(sql: string, rows: R[], suffix: string): R[] {
|
|
170
|
+
if (!isIntrospection(sql)) return rows;
|
|
171
|
+
const listsSchema = /sqlite_(?:master|schema)/i.test(sql);
|
|
172
|
+
const out: R[] = [];
|
|
173
|
+
for (const raw of rows) {
|
|
174
|
+
const row = raw as unknown as Row;
|
|
175
|
+
const name = row["name"];
|
|
176
|
+
if (listsSchema && typeof name === "string") {
|
|
177
|
+
const mine = suffix ? name.endsWith(suffix) : !name.includes("$");
|
|
178
|
+
if (!mine) continue;
|
|
179
|
+
}
|
|
180
|
+
if (!suffix) {
|
|
181
|
+
out.push(raw);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const copy: Row = { ...row };
|
|
185
|
+
for (const k of NAME_KEYS) {
|
|
186
|
+
const v = copy[k];
|
|
187
|
+
if (typeof v === "string" && v.endsWith(suffix)) copy[k] = v.slice(0, -suffix.length);
|
|
188
|
+
}
|
|
189
|
+
// DDL text, whatever the column is called (core selects it as `sql` and as `definition`).
|
|
190
|
+
const sfx = suffix.replace("$", "\\$");
|
|
191
|
+
const bare = new RegExp(`\`([A-Za-z_][A-Za-z0-9_]*)${sfx}\``, "g");
|
|
192
|
+
const own = new RegExp(`${sfx}(?![A-Za-z0-9_$-])`, "g");
|
|
193
|
+
for (const [k, v] of Object.entries(copy)) {
|
|
194
|
+
if (typeof v === "string" && /^\s*create\s/i.test(v)) copy[k] = v.replaceAll(bare, "$1").replaceAll(own, "");
|
|
195
|
+
}
|
|
196
|
+
out.push(copy as unknown as R);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|