fastscript 1.0.0 → 3.0.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/CHANGELOG.md +38 -7
- package/LICENSE +33 -21
- package/README.md +605 -73
- package/node_modules/@fastscript/core-private/BOUNDARY.json +15 -0
- package/node_modules/@fastscript/core-private/README.md +5 -0
- package/node_modules/@fastscript/core-private/package.json +34 -0
- package/node_modules/@fastscript/core-private/src/asset-optimizer.mjs +67 -0
- package/node_modules/@fastscript/core-private/src/audit-log.mjs +50 -0
- package/node_modules/@fastscript/core-private/src/auth-flows.mjs +29 -0
- package/node_modules/@fastscript/core-private/src/auth.mjs +115 -0
- package/node_modules/@fastscript/core-private/src/bench.mjs +45 -0
- package/node_modules/@fastscript/core-private/src/build.mjs +670 -0
- package/node_modules/@fastscript/core-private/src/cache.mjs +248 -0
- package/node_modules/@fastscript/core-private/src/check.mjs +22 -0
- package/node_modules/@fastscript/core-private/src/cli.mjs +95 -0
- package/node_modules/@fastscript/core-private/src/compat.mjs +128 -0
- package/node_modules/@fastscript/core-private/src/create.mjs +278 -0
- package/node_modules/@fastscript/core-private/src/csp.mjs +26 -0
- package/node_modules/@fastscript/core-private/src/db-cli.mjs +185 -0
- package/node_modules/@fastscript/core-private/src/db-postgres-collection.mjs +110 -0
- package/node_modules/@fastscript/core-private/src/db-postgres.mjs +40 -0
- package/node_modules/@fastscript/core-private/src/db.mjs +103 -0
- package/node_modules/@fastscript/core-private/src/deploy.mjs +662 -0
- package/node_modules/@fastscript/core-private/src/dev.mjs +5 -0
- package/node_modules/@fastscript/core-private/src/docs-search.mjs +35 -0
- package/node_modules/@fastscript/core-private/src/env.mjs +118 -0
- package/node_modules/@fastscript/core-private/src/export.mjs +83 -0
- package/node_modules/@fastscript/core-private/src/fs-diagnostics.mjs +70 -0
- package/node_modules/@fastscript/core-private/src/fs-error-codes.mjs +141 -0
- package/node_modules/@fastscript/core-private/src/fs-formatter.mjs +66 -0
- package/node_modules/@fastscript/core-private/src/fs-linter.mjs +274 -0
- package/node_modules/@fastscript/core-private/src/fs-normalize.mjs +121 -0
- package/node_modules/@fastscript/core-private/src/fs-parser.mjs +1120 -0
- package/node_modules/@fastscript/core-private/src/generated/docs-search-index.mjs +3182 -0
- package/node_modules/@fastscript/core-private/src/i18n.mjs +25 -0
- package/node_modules/@fastscript/core-private/src/interop.mjs +16 -0
- package/node_modules/@fastscript/core-private/src/jobs.mjs +378 -0
- package/node_modules/@fastscript/core-private/src/logger.mjs +27 -0
- package/node_modules/@fastscript/core-private/src/metrics.mjs +45 -0
- package/node_modules/@fastscript/core-private/src/middleware.mjs +14 -0
- package/node_modules/@fastscript/core-private/src/migrate.mjs +81 -0
- package/node_modules/@fastscript/core-private/src/migration-wizard.mjs +16 -0
- package/node_modules/@fastscript/core-private/src/module-loader.mjs +46 -0
- package/node_modules/@fastscript/core-private/src/oauth-providers.mjs +103 -0
- package/node_modules/@fastscript/core-private/src/observability.mjs +21 -0
- package/node_modules/@fastscript/core-private/src/plugins.mjs +194 -0
- package/node_modules/@fastscript/core-private/src/retention.mjs +57 -0
- package/node_modules/@fastscript/core-private/src/routes.mjs +178 -0
- package/node_modules/@fastscript/core-private/src/scheduler.mjs +104 -0
- package/node_modules/@fastscript/core-private/src/security.mjs +233 -0
- package/node_modules/@fastscript/core-private/src/server-runtime.mjs +849 -0
- package/node_modules/@fastscript/core-private/src/serverless-handler.mjs +20 -0
- package/node_modules/@fastscript/core-private/src/session-policy.mjs +38 -0
- package/node_modules/@fastscript/core-private/src/start.mjs +10 -0
- package/node_modules/@fastscript/core-private/src/storage.mjs +155 -0
- package/node_modules/@fastscript/core-private/src/style-primitives.mjs +538 -0
- package/node_modules/@fastscript/core-private/src/style-system.mjs +461 -0
- package/node_modules/@fastscript/core-private/src/tenant.mjs +55 -0
- package/node_modules/@fastscript/core-private/src/typecheck.mjs +1466 -0
- package/node_modules/@fastscript/core-private/src/validate.mjs +22 -0
- package/node_modules/@fastscript/core-private/src/validation.mjs +88 -0
- package/node_modules/@fastscript/core-private/src/webhook.mjs +81 -0
- package/node_modules/@fastscript/core-private/src/worker.mjs +24 -0
- package/package.json +108 -14
- package/src/asset-optimizer.mjs +67 -0
- package/src/audit-log.mjs +50 -0
- package/src/auth.mjs +1 -115
- package/src/bench.mjs +20 -7
- package/src/benchmark-discipline.mjs +39 -0
- package/src/build.mjs +1 -234
- package/src/cache.mjs +210 -20
- package/src/cli.mjs +65 -6
- package/src/compat.mjs +8 -10
- package/src/conversion-manifest.mjs +101 -0
- package/src/create.mjs +71 -17
- package/src/csp.mjs +26 -0
- package/src/db-cli.mjs +152 -8
- package/src/db-postgres-collection.mjs +110 -0
- package/src/deploy.mjs +1 -65
- package/src/diagnostics.mjs +100 -0
- package/src/docs-search.mjs +35 -0
- package/src/env.mjs +34 -5
- package/src/fs-diagnostics.mjs +70 -0
- package/src/fs-error-codes.mjs +126 -0
- package/src/fs-formatter.mjs +66 -0
- package/src/fs-linter.mjs +274 -0
- package/src/fs-normalize.mjs +52 -239
- package/src/fs-parser.mjs +1 -0
- package/src/generated/docs-search-index.mjs +3591 -0
- package/src/i18n.mjs +25 -0
- package/src/jobs.mjs +283 -32
- package/src/metrics.mjs +45 -0
- package/src/migrate-rollback.mjs +144 -0
- package/src/migrate.mjs +1275 -47
- package/src/migration-wizard.mjs +42 -0
- package/src/module-loader.mjs +22 -11
- package/src/oauth-providers.mjs +103 -0
- package/src/permissions-cli.mjs +112 -0
- package/src/plugins.mjs +194 -0
- package/src/profile.mjs +95 -0
- package/src/regression-guard.mjs +245 -0
- package/src/retention.mjs +57 -0
- package/src/routes.mjs +178 -0
- package/src/runtime-permissions.mjs +299 -0
- package/src/scheduler.mjs +104 -0
- package/src/security.mjs +197 -19
- package/src/server-runtime.mjs +1 -339
- package/src/serverless-handler.mjs +20 -0
- package/src/session-policy.mjs +38 -0
- package/src/storage.mjs +1 -56
- package/src/style-system.mjs +461 -0
- package/src/tenant.mjs +55 -0
- package/src/trace.mjs +95 -0
- package/src/typecheck.mjs +1 -0
- package/src/validate.mjs +13 -1
- package/src/validation.mjs +14 -5
- package/src/webhook.mjs +1 -71
- package/src/worker.mjs +23 -4
- package/src/language-spec.mjs +0 -58
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
function ensureDir(path) {
|
|
5
|
+
if (!existsSync(path)) mkdirSync(path, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function createApp(target = "app", { template = "default" } = {}) {
|
|
9
|
+
const root = process.cwd();
|
|
10
|
+
const appRoot = join(root, target);
|
|
11
|
+
const pagesRoot = join(appRoot, "pages");
|
|
12
|
+
const templateDir = join(root, "examples", template, "app");
|
|
13
|
+
|
|
14
|
+
if (template !== "default" && existsSync(templateDir)) {
|
|
15
|
+
ensureDir(appRoot);
|
|
16
|
+
cpSync(templateDir, appRoot, { recursive: true });
|
|
17
|
+
console.log(`created ${target} from template: ${template}`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
ensureDir(pagesRoot);
|
|
22
|
+
ensureDir(join(appRoot, "api"));
|
|
23
|
+
ensureDir(join(appRoot, "db", "migrations"));
|
|
24
|
+
|
|
25
|
+
const files = [
|
|
26
|
+
{
|
|
27
|
+
path: join(pagesRoot, "index.fs"),
|
|
28
|
+
content: `export default function Home() {
|
|
29
|
+
~title = "Build full-stack apps with FastScript."
|
|
30
|
+
return \`
|
|
31
|
+
<Screen pad="6" surface="panel">
|
|
32
|
+
<Container>
|
|
33
|
+
<Stack gap="5" pad="6" radius="lg" shadow="soft" surface="card">
|
|
34
|
+
<Text tone="primary" size="sm" weight="semibold">FastScript starter</Text>
|
|
35
|
+
<Heading size="3xl">\${title}</Heading>
|
|
36
|
+
<Text tone="muted" size="lg">Simple syntax, fast compiler pipeline, and one full-stack runtime from pages to APIs.</Text>
|
|
37
|
+
<Row gap="3" align="center">
|
|
38
|
+
<Button tone="primary" size="lg" data-fs-counter>Counter: <span data-fs-counter-value>0</span></Button>
|
|
39
|
+
<Button tone="ghost" size="lg" href="/private">Open private page</Button>
|
|
40
|
+
</Row>
|
|
41
|
+
</Stack>
|
|
42
|
+
</Container>
|
|
43
|
+
</Screen>
|
|
44
|
+
\`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function hydrate({ root }) {
|
|
48
|
+
const btn = root.querySelector("[data-fs-counter]");
|
|
49
|
+
const value = root.querySelector("[data-fs-counter-value]");
|
|
50
|
+
if (!btn || !value) return;
|
|
51
|
+
let n = Number(value.textContent || "0");
|
|
52
|
+
btn.addEventListener("click", () => {
|
|
53
|
+
n += 1;
|
|
54
|
+
value.textContent = String(n);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
`,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
path: join(pagesRoot, "_layout.fs"),
|
|
61
|
+
content: `export default function Layout({ content, pathname, user }) {
|
|
62
|
+
return \`
|
|
63
|
+
<Screen surface="plain">
|
|
64
|
+
<Box class="nav">
|
|
65
|
+
<Row justify="between" align="center">
|
|
66
|
+
<Row gap="3" align="center">
|
|
67
|
+
<Text weight="bold">FastScript</Text>
|
|
68
|
+
<Badge tone="muted">starter</Badge>
|
|
69
|
+
</Row>
|
|
70
|
+
<Row gap="3" align="center">
|
|
71
|
+
<Link href="/">Home</Link>
|
|
72
|
+
<Link href="/private">Private</Link>
|
|
73
|
+
<Text tone="muted" size="sm">\${user ? "Signed in" : "Guest"}</Text>
|
|
74
|
+
</Row>
|
|
75
|
+
</Row>
|
|
76
|
+
</Box>
|
|
77
|
+
<main class="page">\${content}</main>
|
|
78
|
+
<footer class="footer">
|
|
79
|
+
<Container>
|
|
80
|
+
<Text tone="muted">Built with FastScript</Text>
|
|
81
|
+
</Container>
|
|
82
|
+
</footer>
|
|
83
|
+
</Screen>
|
|
84
|
+
\`;
|
|
85
|
+
}
|
|
86
|
+
`,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
path: join(pagesRoot, "404.fs"),
|
|
90
|
+
content: `export default function NotFound() {
|
|
91
|
+
return \`
|
|
92
|
+
<Screen pad="6">
|
|
93
|
+
<Container>
|
|
94
|
+
<Stack gap="3" surface="panel" pad="6" radius="lg">
|
|
95
|
+
<Heading size="2xl">404</Heading>
|
|
96
|
+
<Text tone="muted">Page not found.</Text>
|
|
97
|
+
<Row gap="3">
|
|
98
|
+
<Button tone="primary" href="/">Go home</Button>
|
|
99
|
+
</Row>
|
|
100
|
+
</Stack>
|
|
101
|
+
</Container>
|
|
102
|
+
</Screen>
|
|
103
|
+
\`;
|
|
104
|
+
}
|
|
105
|
+
`,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
path: join(pagesRoot, "private.fs"),
|
|
109
|
+
content: `export default function PrivatePage({ user }) {
|
|
110
|
+
return \`
|
|
111
|
+
<Screen pad="6">
|
|
112
|
+
<Container>
|
|
113
|
+
<Stack gap="3" surface="panel" pad="6" radius="lg">
|
|
114
|
+
<Heading size="2xl">Private</Heading>
|
|
115
|
+
<Text tone="muted">Hello \${user?.name ?? "anonymous"}</Text>
|
|
116
|
+
</Stack>
|
|
117
|
+
</Container>
|
|
118
|
+
</Screen>
|
|
119
|
+
\`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function GET(ctx) {
|
|
123
|
+
try {
|
|
124
|
+
const user = ctx.auth.requireUser();
|
|
125
|
+
return ctx.helpers.json({ ok: true, user });
|
|
126
|
+
} catch {
|
|
127
|
+
return ctx.helpers.redirect("/");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
`,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
path: join(appRoot, "styles.css"),
|
|
134
|
+
content: `:root { color-scheme: dark; }
|
|
135
|
+
* { box-sizing: border-box; }
|
|
136
|
+
body { margin: 0; font: 16px/1.6 ui-sans-serif, system-ui; background: var(--fs-color-bg); color: var(--fs-color-text); }
|
|
137
|
+
.nav { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 16px 24px; border-bottom: 1px solid var(--fs-color-border); }
|
|
138
|
+
.nav a { color: var(--fs-color-accentSoft); text-decoration: none; margin-right: 12px; }
|
|
139
|
+
.page { max-width: 980px; margin: 0 auto; padding: 40px 24px; }
|
|
140
|
+
.footer { border-top: 1px solid var(--fs-color-border); padding: 24px; color: var(--fs-color-muted); }
|
|
141
|
+
`,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
path: join(appRoot, "design", "tokens.json"),
|
|
145
|
+
content: `{
|
|
146
|
+
"color": {
|
|
147
|
+
"bg": "#050505",
|
|
148
|
+
"surface": "#090909",
|
|
149
|
+
"text": "#ffffff",
|
|
150
|
+
"muted": "#8a8a8a",
|
|
151
|
+
"border": "#1f1f1f",
|
|
152
|
+
"accent": "#9f92ff",
|
|
153
|
+
"accentSoft": "#d3d3ff"
|
|
154
|
+
},
|
|
155
|
+
"space": {
|
|
156
|
+
"1": "4px",
|
|
157
|
+
"2": "8px",
|
|
158
|
+
"3": "12px",
|
|
159
|
+
"4": "16px",
|
|
160
|
+
"5": "20px",
|
|
161
|
+
"6": "24px",
|
|
162
|
+
"8": "32px",
|
|
163
|
+
"10": "40px",
|
|
164
|
+
"12": "48px"
|
|
165
|
+
},
|
|
166
|
+
"radius": {
|
|
167
|
+
"sm": "8px",
|
|
168
|
+
"md": "12px",
|
|
169
|
+
"lg": "16px"
|
|
170
|
+
},
|
|
171
|
+
"shadow": {
|
|
172
|
+
"soft": "0 10px 40px rgba(0,0,0,0.22)"
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
`,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
path: join(appRoot, "design", "class-allowlist.json"),
|
|
179
|
+
content: `[
|
|
180
|
+
"nav",
|
|
181
|
+
"page",
|
|
182
|
+
"footer"
|
|
183
|
+
]
|
|
184
|
+
`,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
path: join(appRoot, "api", "hello.js"),
|
|
188
|
+
content: `export async function GET() {
|
|
189
|
+
return { status: 200, json: { ok: true, message: "Hello from FastScript API" } };
|
|
190
|
+
}
|
|
191
|
+
`,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
path: join(appRoot, "api", "auth.js"),
|
|
195
|
+
content: `export const schemas = {
|
|
196
|
+
POST: { name: "string?" }
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export async function POST(ctx) {
|
|
200
|
+
const body = await ctx.input.validateBody(schemas.POST);
|
|
201
|
+
const user = { id: "u_1", name: body.name || "Dev" };
|
|
202
|
+
ctx.auth.login(user);
|
|
203
|
+
return ctx.helpers.json({ ok: true, user });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function DELETE(ctx) {
|
|
207
|
+
ctx.auth.logout();
|
|
208
|
+
return ctx.helpers.json({ ok: true });
|
|
209
|
+
}
|
|
210
|
+
`,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
path: join(appRoot, "api", "upload.js"),
|
|
214
|
+
content: `export const schemas = {
|
|
215
|
+
POST: { key: "string", content: "string", acl: "string?" }
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export async function POST(ctx) {
|
|
219
|
+
const body = await ctx.input.validateBody(schemas.POST);
|
|
220
|
+
const put = ctx.storage.put(body.key, Buffer.from(body.content, "utf8"), { acl: body.acl || "public" });
|
|
221
|
+
const signedUrl = ctx.storage.signedUrl ? ctx.storage.signedUrl(body.key, { action: "get", expiresInSec: 900 }) : null;
|
|
222
|
+
return ctx.helpers.json({ ok: true, ...put, url: ctx.storage.url(body.key), signedUrl });
|
|
223
|
+
}
|
|
224
|
+
`,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
path: join(appRoot, "api", "webhook.js"),
|
|
228
|
+
content: `import { verifyWebhookRequest } from "../../src/webhook.mjs";
|
|
229
|
+
|
|
230
|
+
export async function POST(ctx) {
|
|
231
|
+
const result = await verifyWebhookRequest(ctx.req, {
|
|
232
|
+
secret: process.env.WEBHOOK_SECRET || "dev-secret",
|
|
233
|
+
replayDir: ".fastscript"
|
|
234
|
+
});
|
|
235
|
+
if (!result.ok) return ctx.helpers.json({ ok: false, reason: result.reason }, 401);
|
|
236
|
+
return ctx.helpers.json({ ok: true });
|
|
237
|
+
}
|
|
238
|
+
`,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
path: join(appRoot, "middleware.fs"),
|
|
242
|
+
content: `export async function middleware(ctx, next) {
|
|
243
|
+
const protectedRoute = ctx.pathname.startsWith("/private");
|
|
244
|
+
if (protectedRoute && !ctx.user) {
|
|
245
|
+
return ctx.helpers.redirect("/");
|
|
246
|
+
}
|
|
247
|
+
return next();
|
|
248
|
+
}
|
|
249
|
+
`,
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
path: join(appRoot, "db", "migrations", "001_init.js"),
|
|
253
|
+
content: `export async function up(db) {
|
|
254
|
+
const users = db.collection("users");
|
|
255
|
+
if (!users.get("u_1")) {
|
|
256
|
+
users.set("u_1", { id: "u_1", name: "Dev" });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
`,
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
path: join(appRoot, "db", "seed.js"),
|
|
263
|
+
content: `export async function seed(db) {
|
|
264
|
+
db.transaction((tx) => {
|
|
265
|
+
tx.collection("posts").set("hello", { id: "hello", title: "First Post", published: true });
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
`,
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
for (const file of files) {
|
|
273
|
+
if (!existsSync(file.path)) writeFileSync(file.path, file.content, "utf8");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const count = readdirSync(pagesRoot).length;
|
|
277
|
+
console.log(`created ${target} with ${count} page file(s)`);
|
|
278
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const BASE_DIRECTIVES = {
|
|
2
|
+
"default-src": ["'self'"],
|
|
3
|
+
"img-src": ["'self'", "data:", "https:"],
|
|
4
|
+
"style-src": ["'self'", "'unsafe-inline'"],
|
|
5
|
+
"font-src": ["'self'", "data:", "https:"],
|
|
6
|
+
"connect-src": ["'self'"],
|
|
7
|
+
"script-src": ["'self'"],
|
|
8
|
+
"frame-ancestors": ["'none'"],
|
|
9
|
+
"base-uri": ["'self'"],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function generateCspPolicy({ target = "node", mode = process.env.NODE_ENV || "development", nonce = "" } = {}) {
|
|
13
|
+
const directives = structuredClone(BASE_DIRECTIVES);
|
|
14
|
+
if (target === "vercel" || target === "cloudflare") {
|
|
15
|
+
directives["connect-src"].push("https://*.vercel.app", "https://*.workers.dev");
|
|
16
|
+
}
|
|
17
|
+
if (mode !== "production") {
|
|
18
|
+
directives["connect-src"].push("ws:", "wss:");
|
|
19
|
+
directives["script-src"].push("'unsafe-eval'");
|
|
20
|
+
}
|
|
21
|
+
if (nonce) directives["script-src"].push(`'nonce-${nonce}'`);
|
|
22
|
+
|
|
23
|
+
return Object.entries(directives)
|
|
24
|
+
.map(([key, values]) => `${key} ${[...new Set(values)].join(" ")}`)
|
|
25
|
+
.join("; ");
|
|
26
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { createFileDatabase } from "./db.mjs";
|
|
4
|
+
import { createPostgresCollectionDatabase } from "./db-postgres-collection.mjs";
|
|
5
|
+
import { importSourceModule } from "./module-loader.mjs";
|
|
6
|
+
|
|
7
|
+
const FASTSCRIPT_DIR = resolve(".fastscript");
|
|
8
|
+
const MIGRATIONS_DIR = resolve("app/db/migrations");
|
|
9
|
+
const MIGRATION_LEDGER = join(FASTSCRIPT_DIR, "migrations.json");
|
|
10
|
+
const SEED_FILES = [resolve("app/db/seed.fs"), resolve("app/db/seed.js"), resolve("app/db/seed.mjs"), resolve("app/db/seed.cjs")];
|
|
11
|
+
|
|
12
|
+
function readLedgerFile() {
|
|
13
|
+
if (!existsSync(MIGRATION_LEDGER)) return { applied: [] };
|
|
14
|
+
try {
|
|
15
|
+
const raw = JSON.parse(readFileSync(MIGRATION_LEDGER, "utf8"));
|
|
16
|
+
return { applied: Array.isArray(raw.applied) ? raw.applied : [] };
|
|
17
|
+
} catch {
|
|
18
|
+
return { applied: [] };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeLedgerFile(applied) {
|
|
23
|
+
mkdirSync(FASTSCRIPT_DIR, { recursive: true });
|
|
24
|
+
writeFileSync(MIGRATION_LEDGER, JSON.stringify({ applied: [...new Set(applied)].sort() }, null, 2), "utf8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function createLedgerAdapter(driver = (process.env.DB_DRIVER || "file").toLowerCase()) {
|
|
28
|
+
if (driver !== "postgres") {
|
|
29
|
+
return {
|
|
30
|
+
type: "file",
|
|
31
|
+
async listApplied() {
|
|
32
|
+
return readLedgerFile().applied;
|
|
33
|
+
},
|
|
34
|
+
async markApplied(id) {
|
|
35
|
+
const current = new Set(readLedgerFile().applied);
|
|
36
|
+
current.add(id);
|
|
37
|
+
writeLedgerFile([...current]);
|
|
38
|
+
},
|
|
39
|
+
async markRolledBack(id) {
|
|
40
|
+
const current = new Set(readLedgerFile().applied);
|
|
41
|
+
current.delete(id);
|
|
42
|
+
writeLedgerFile([...current]);
|
|
43
|
+
},
|
|
44
|
+
async close() {},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const { Client } = await import("pg");
|
|
50
|
+
const client = new Client({ connectionString: process.env.DATABASE_URL });
|
|
51
|
+
await client.connect();
|
|
52
|
+
await client.query(`
|
|
53
|
+
CREATE TABLE IF NOT EXISTS fs_migrations_ledger (
|
|
54
|
+
id text PRIMARY KEY,
|
|
55
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
56
|
+
);
|
|
57
|
+
`);
|
|
58
|
+
return {
|
|
59
|
+
type: "postgres",
|
|
60
|
+
async listApplied() {
|
|
61
|
+
const rows = (await client.query("SELECT id FROM fs_migrations_ledger ORDER BY applied_at ASC")).rows;
|
|
62
|
+
return rows.map((row) => row.id);
|
|
63
|
+
},
|
|
64
|
+
async markApplied(id) {
|
|
65
|
+
await client.query(
|
|
66
|
+
"INSERT INTO fs_migrations_ledger(id) VALUES($1) ON CONFLICT(id) DO NOTHING",
|
|
67
|
+
[id],
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
async markRolledBack(id) {
|
|
71
|
+
await client.query("DELETE FROM fs_migrations_ledger WHERE id=$1", [id]);
|
|
72
|
+
},
|
|
73
|
+
async close() {
|
|
74
|
+
await client.end();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
} catch {
|
|
78
|
+
return createLedgerAdapter("file");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function createMigrationDatabase(driver = (process.env.DB_DRIVER || "file").toLowerCase()) {
|
|
83
|
+
if (driver !== "postgres") return createFileDatabase({ dir: ".fastscript", name: "appdb" });
|
|
84
|
+
try {
|
|
85
|
+
return await createPostgresCollectionDatabase({ connectionString: process.env.DATABASE_URL });
|
|
86
|
+
} catch {
|
|
87
|
+
return createFileDatabase({ dir: ".fastscript", name: "appdb" });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function migrationFiles() {
|
|
92
|
+
if (!existsSync(MIGRATIONS_DIR)) return [];
|
|
93
|
+
return readdirSync(MIGRATIONS_DIR).filter((file) => /\.(fs|js|mjs|cjs)$/.test(file)).sort();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function runDbMigrate() {
|
|
97
|
+
const driver = (process.env.DB_DRIVER || "file").toLowerCase();
|
|
98
|
+
const db = await createMigrationDatabase(driver);
|
|
99
|
+
const ledger = await createLedgerAdapter(driver);
|
|
100
|
+
if (!existsSync(MIGRATIONS_DIR)) {
|
|
101
|
+
console.log("db migrate: no app/db/migrations directory");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const done = new Set(await ledger.listApplied());
|
|
106
|
+
const files = migrationFiles();
|
|
107
|
+
let count = 0;
|
|
108
|
+
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
if (done.has(file)) {
|
|
111
|
+
console.log(`db migrate: skipped ${file} (already applied)`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const mod = await importSourceModule(join(MIGRATIONS_DIR, file), { platform: "node" });
|
|
115
|
+
const fn = mod.up ?? mod.default;
|
|
116
|
+
if (typeof fn !== "function") {
|
|
117
|
+
console.log(`db migrate: skipped ${file} (missing up/default export)`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
await fn(db);
|
|
121
|
+
await ledger.markApplied(file);
|
|
122
|
+
done.add(file);
|
|
123
|
+
count += 1;
|
|
124
|
+
console.log(`db migrate: applied ${file}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (db?.flush) await db.flush();
|
|
128
|
+
if (db?.close) await db.close();
|
|
129
|
+
await ledger.close();
|
|
130
|
+
|
|
131
|
+
console.log(`db migrate complete: ${count} migration(s)`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function runDbRollback(args = []) {
|
|
135
|
+
const driver = (process.env.DB_DRIVER || "file").toLowerCase();
|
|
136
|
+
const db = await createMigrationDatabase(driver);
|
|
137
|
+
const ledger = await createLedgerAdapter(driver);
|
|
138
|
+
const countFlag = args.indexOf("--count");
|
|
139
|
+
const count = Math.max(1, Number(countFlag >= 0 ? args[countFlag + 1] || 1 : 1));
|
|
140
|
+
const applied = await ledger.listApplied();
|
|
141
|
+
const target = applied.slice(-count).reverse();
|
|
142
|
+
|
|
143
|
+
if (!target.length) {
|
|
144
|
+
console.log("db rollback: nothing to rollback");
|
|
145
|
+
if (db?.close) await db.close();
|
|
146
|
+
await ledger.close();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let rolledBack = 0;
|
|
151
|
+
for (const id of target) {
|
|
152
|
+
const mod = await importSourceModule(join(MIGRATIONS_DIR, id), { platform: "node" });
|
|
153
|
+
const fn = mod.down;
|
|
154
|
+
if (typeof fn !== "function") {
|
|
155
|
+
throw new Error(`db rollback: migration ${id} does not export down(db)`);
|
|
156
|
+
}
|
|
157
|
+
await fn(db);
|
|
158
|
+
await ledger.markRolledBack(id);
|
|
159
|
+
rolledBack += 1;
|
|
160
|
+
console.log(`db rollback: reverted ${id}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (db?.flush) await db.flush();
|
|
164
|
+
if (db?.close) await db.close();
|
|
165
|
+
await ledger.close();
|
|
166
|
+
console.log(`db rollback complete: ${rolledBack} migration(s)`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function runDbSeed() {
|
|
170
|
+
const driver = (process.env.DB_DRIVER || "file").toLowerCase();
|
|
171
|
+
const db = await createMigrationDatabase(driver);
|
|
172
|
+
const seedFile = SEED_FILES.find((p) => existsSync(p));
|
|
173
|
+
if (!seedFile) {
|
|
174
|
+
console.log("db seed: no app/db/seed file");
|
|
175
|
+
if (db?.close) await db.close();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const mod = await importSourceModule(seedFile, { platform: "node" });
|
|
179
|
+
const fn = mod.seed ?? mod.default;
|
|
180
|
+
if (typeof fn !== "function") throw new Error("app/db/seed must export seed(db) or default(db)");
|
|
181
|
+
await fn(db);
|
|
182
|
+
if (db?.flush) await db.flush();
|
|
183
|
+
if (db?.close) await db.close();
|
|
184
|
+
console.log("db seed complete");
|
|
185
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export async function createPostgresCollectionDatabase({ connectionString = process.env.DATABASE_URL } = {}) {
|
|
2
|
+
const { Client } = await import("pg");
|
|
3
|
+
const client = new Client({ connectionString });
|
|
4
|
+
await client.connect();
|
|
5
|
+
await client.query(`
|
|
6
|
+
CREATE TABLE IF NOT EXISTS fs_records (
|
|
7
|
+
collection text NOT NULL,
|
|
8
|
+
id text NOT NULL,
|
|
9
|
+
data jsonb NOT NULL,
|
|
10
|
+
PRIMARY KEY(collection, id)
|
|
11
|
+
);
|
|
12
|
+
`);
|
|
13
|
+
|
|
14
|
+
const state = { collections: {} };
|
|
15
|
+
const rows = (await client.query("SELECT collection, id, data FROM fs_records")).rows;
|
|
16
|
+
for (const row of rows) {
|
|
17
|
+
if (!state.collections[row.collection]) state.collections[row.collection] = {};
|
|
18
|
+
state.collections[row.collection][row.id] = row.data;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pending = new Set();
|
|
22
|
+
function enqueue(op) {
|
|
23
|
+
const p = Promise.resolve().then(op).catch(() => {}).finally(() => pending.delete(p));
|
|
24
|
+
pending.add(p);
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
function ensureCollection(collection) {
|
|
28
|
+
if (!state.collections[collection]) state.collections[collection] = {};
|
|
29
|
+
return state.collections[collection];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const db = {
|
|
33
|
+
collection(name) {
|
|
34
|
+
return {
|
|
35
|
+
get(id) {
|
|
36
|
+
const col = ensureCollection(name);
|
|
37
|
+
return col[id] ?? null;
|
|
38
|
+
},
|
|
39
|
+
set(id, value) {
|
|
40
|
+
const col = ensureCollection(name);
|
|
41
|
+
col[id] = value;
|
|
42
|
+
enqueue(() => client.query(
|
|
43
|
+
"INSERT INTO fs_records(collection, id, data) VALUES($1,$2,$3::jsonb) ON CONFLICT(collection,id) DO UPDATE SET data=excluded.data",
|
|
44
|
+
[name, id, JSON.stringify(value)],
|
|
45
|
+
));
|
|
46
|
+
return col[id];
|
|
47
|
+
},
|
|
48
|
+
delete(id) {
|
|
49
|
+
const col = ensureCollection(name);
|
|
50
|
+
delete col[id];
|
|
51
|
+
enqueue(() => client.query("DELETE FROM fs_records WHERE collection=$1 AND id=$2", [name, id]));
|
|
52
|
+
},
|
|
53
|
+
all() {
|
|
54
|
+
const col = ensureCollection(name);
|
|
55
|
+
return Object.values(col);
|
|
56
|
+
},
|
|
57
|
+
upsert(id, updater) {
|
|
58
|
+
const col = ensureCollection(name);
|
|
59
|
+
const prev = col[id] ?? null;
|
|
60
|
+
const next = typeof updater === "function" ? updater(prev) : updater;
|
|
61
|
+
col[id] = next;
|
|
62
|
+
enqueue(() => client.query(
|
|
63
|
+
"INSERT INTO fs_records(collection, id, data) VALUES($1,$2,$3::jsonb) ON CONFLICT(collection,id) DO UPDATE SET data=excluded.data",
|
|
64
|
+
[name, id, JSON.stringify(next)],
|
|
65
|
+
));
|
|
66
|
+
return next;
|
|
67
|
+
},
|
|
68
|
+
first(predicate) {
|
|
69
|
+
const col = ensureCollection(name);
|
|
70
|
+
return Object.values(col).find(predicate) ?? null;
|
|
71
|
+
},
|
|
72
|
+
where(filters) {
|
|
73
|
+
const col = ensureCollection(name);
|
|
74
|
+
if (typeof filters === "function") return Object.values(col).filter(filters);
|
|
75
|
+
return Object.values(col).filter((row) =>
|
|
76
|
+
Object.entries(filters || {}).every(([k, v]) => row?.[k] === v),
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
transaction(fn) {
|
|
82
|
+
// In-memory atomic mutation, writes are enqueued asynchronously.
|
|
83
|
+
return fn(db);
|
|
84
|
+
},
|
|
85
|
+
query(collection, predicate) {
|
|
86
|
+
const col = ensureCollection(collection);
|
|
87
|
+
return Object.values(col).filter(predicate);
|
|
88
|
+
},
|
|
89
|
+
first(collection, predicate) {
|
|
90
|
+
const col = ensureCollection(collection);
|
|
91
|
+
return Object.values(col).find(predicate) ?? null;
|
|
92
|
+
},
|
|
93
|
+
where(collection, filters) {
|
|
94
|
+
const col = ensureCollection(collection);
|
|
95
|
+
if (typeof filters === "function") return Object.values(col).filter(filters);
|
|
96
|
+
return Object.values(col).filter((row) =>
|
|
97
|
+
Object.entries(filters || {}).every(([k, v]) => row?.[k] === v),
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
async flush() {
|
|
101
|
+
await Promise.all([...pending]);
|
|
102
|
+
},
|
|
103
|
+
async close() {
|
|
104
|
+
await Promise.all([...pending]);
|
|
105
|
+
await client.end();
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return db;
|
|
110
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export async function createPostgresAdapter({ connectionString = process.env.DATABASE_URL } = {}) {
|
|
2
|
+
const { Client } = await import("pg");
|
|
3
|
+
const client = new Client({ connectionString });
|
|
4
|
+
await client.connect();
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
async query(sql, params = []) {
|
|
8
|
+
const res = await client.query(sql, params);
|
|
9
|
+
return res.rows;
|
|
10
|
+
},
|
|
11
|
+
async transaction(fn) {
|
|
12
|
+
await client.query("BEGIN");
|
|
13
|
+
try {
|
|
14
|
+
const out = await fn({ query: (sql, params = []) => client.query(sql, params).then((r) => r.rows) });
|
|
15
|
+
await client.query("COMMIT");
|
|
16
|
+
return out;
|
|
17
|
+
} catch (e) {
|
|
18
|
+
await client.query("ROLLBACK");
|
|
19
|
+
throw e;
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
async migrate(lockId = 4839201, migrations = []) {
|
|
23
|
+
await client.query("SELECT pg_advisory_lock($1)", [lockId]);
|
|
24
|
+
try {
|
|
25
|
+
await client.query("CREATE TABLE IF NOT EXISTS fs_migrations (id text primary key, applied_at timestamptz not null default now())");
|
|
26
|
+
const done = new Set((await client.query("SELECT id FROM fs_migrations")).rows.map((r) => r.id));
|
|
27
|
+
for (const m of migrations) {
|
|
28
|
+
if (done.has(m.id)) continue;
|
|
29
|
+
await m.up({ query: (sql, params = []) => client.query(sql, params).then((r) => r.rows) });
|
|
30
|
+
await client.query("INSERT INTO fs_migrations(id) VALUES($1)", [m.id]);
|
|
31
|
+
}
|
|
32
|
+
} finally {
|
|
33
|
+
await client.query("SELECT pg_advisory_unlock($1)", [lockId]);
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
async close() {
|
|
37
|
+
await client.end();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|