edgeway 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +270 -9
- data/js/cloudflare-d1-sessions.js +68 -0
- data/js/cloudflare.js +123 -22
- data/js/cors.js +15 -16
- data/js/headers.js +16 -0
- data/js/jwt.js +1 -1
- data/lib/edgeway/application.rb +6 -3
- data/lib/edgeway/cli/build/app_config.rb +39 -0
- data/lib/edgeway/cli/build/app_files.rb +40 -0
- data/lib/edgeway/cli/build/database_config.rb +92 -0
- data/lib/edgeway/cli/build/view_compiler.rb +191 -0
- data/lib/edgeway/cli/build.rb +36 -11
- data/lib/edgeway/cli/generate.rb +174 -26
- data/lib/edgeway/cli/new.rb +1 -1
- data/lib/edgeway/cli/test.rb +4 -1
- data/lib/edgeway/cli.rb +5 -4
- data/lib/edgeway/controller/base.rb +12 -2
- data/lib/edgeway/controller/dispatcher.rb +11 -1
- data/lib/edgeway/controller/rendering.rb +24 -4
- data/lib/edgeway/db/adapter.rb +38 -0
- data/lib/edgeway/db/memory.rb +9 -2
- data/lib/edgeway/db/query_stats.rb +46 -0
- data/lib/edgeway/db/sqlite.rb +73 -9
- data/lib/edgeway/errors.rb +22 -0
- data/lib/edgeway/generators/app/templates/.github/workflows/ci.yml.tt +38 -0
- data/lib/edgeway/generators/app/templates/.ruby-version.tt +1 -0
- data/lib/edgeway/generators/app/templates/README.md.tt +6 -2
- data/lib/edgeway/generators/app/templates/app/views/layouts/application.html.erb +11 -0
- data/lib/edgeway/generators/app/templates/bin/build.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/deploy.tt +1 -1
- data/lib/edgeway/generators/app/templates/bin/test.tt +7 -0
- data/lib/edgeway/generators/app/templates/config/database.yml +17 -0
- data/lib/edgeway/generators/app/templates/mise.toml.tt +1 -1
- data/lib/edgeway/generators/app/templates/wrangler.toml.tt +7 -0
- data/lib/edgeway/http/auth/stub.rb +1 -3
- data/lib/edgeway/http/request.rb +2 -22
- data/lib/edgeway/inflector.rb +19 -0
- data/lib/edgeway/model/attributes.rb +93 -10
- data/lib/edgeway/model/base.rb +50 -5
- data/lib/edgeway/model/connection_registry.rb +25 -0
- data/lib/edgeway/model/macros.rb +5 -1
- data/lib/edgeway/model/persistence.rb +18 -11
- data/lib/edgeway/model/relation.rb +106 -56
- data/lib/edgeway/model/validations.rb +29 -23
- data/lib/edgeway/routing/route.rb +1 -6
- data/lib/edgeway/server.rb +12 -0
- data/lib/edgeway/service/cloudflare/d1.rb +39 -10
- data/lib/edgeway/test.rb +29 -8
- data/lib/edgeway/version.rb +1 -1
- data/lib/edgeway/view.rb +24 -0
- data/lib/edgeway.rb +3 -0
- metadata +17 -4
- data/lib/edgeway/cli/app_config.rb +0 -35
- data/lib/edgeway/cli/app_files.rb +0 -37
data/js/cloudflare.js
CHANGED
|
@@ -5,20 +5,101 @@
|
|
|
5
5
|
// export default createWorker(app);
|
|
6
6
|
//
|
|
7
7
|
// build/edgeway/config.js is written by `edgeway build` from
|
|
8
|
-
// config/database.yml: the environment name
|
|
8
|
+
// config/database.yml: the environment name, the D1 binding, and the bindings
|
|
9
|
+
// that use D1 Sessions.
|
|
9
10
|
import { runCgi } from "./runtime.js";
|
|
10
11
|
import config from "./config.js";
|
|
11
12
|
import { jwtImports } from "./jwt.js";
|
|
12
13
|
import { withCors } from "./cors.js";
|
|
14
|
+
import { BOOKMARK_HEADER, D1Sessions } from "./cloudflare-d1-sessions.js";
|
|
15
|
+
|
|
16
|
+
// D1's transient errors, which are worth a retry by the client
|
|
17
|
+
// (developers.cloudflare.com/d1/observability/debug-d1/). D1 retries read-only
|
|
18
|
+
// queries itself; Edgeway never retries, as a write is not idempotent.
|
|
19
|
+
// "exceeded", "CPU time limit", "memory limit" and "timeout" are not here.
|
|
20
|
+
export const TRANSIENT_D1_ERRORS = [
|
|
21
|
+
"Network connection lost",
|
|
22
|
+
"reset because its code was updated",
|
|
23
|
+
"storage caused object to be reset",
|
|
24
|
+
"Replica disconnected from primary",
|
|
25
|
+
"transient issue on remote node",
|
|
26
|
+
"Can't read from request stream because client disconnected",
|
|
27
|
+
"D1 DB is overloaded",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const isTransientD1Error = (message) => TRANSIENT_D1_ERRORS.some((s) => message.includes(s));
|
|
31
|
+
|
|
32
|
+
// Counts are 32-bit in wasm: a larger one is clamped.
|
|
33
|
+
const MAX_COUNT = 2147483647;
|
|
34
|
+
|
|
35
|
+
// D1's result.meta in the shape of META above. A local D1 (wrangler dev) may
|
|
36
|
+
// leave fields out.
|
|
37
|
+
export function metaOf(meta) {
|
|
38
|
+
return {
|
|
39
|
+
rows_read: Math.min(meta?.rows_read ?? 0, MAX_COUNT),
|
|
40
|
+
rows_written: Math.min(meta?.rows_written ?? 0, MAX_COUNT),
|
|
41
|
+
duration: meta?.duration ?? 0,
|
|
42
|
+
served_by_region: meta?.served_by_region ?? "",
|
|
43
|
+
served_by_primary: meta?.served_by_primary ?? true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function sumMeta(metas) {
|
|
48
|
+
const all = metas.map(metaOf);
|
|
49
|
+
const sum = (field) => all.reduce((total, meta) => total + meta[field], 0);
|
|
50
|
+
return {
|
|
51
|
+
...all[all.length - 1],
|
|
52
|
+
rows_read: Math.min(sum("rows_read"), MAX_COUNT),
|
|
53
|
+
rows_written: Math.min(sum("rows_written"), MAX_COUNT),
|
|
54
|
+
duration: sum("duration"),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A BLOB crosses as {"$blob": "<base64>"}. D1 binds an ArrayBuffer as a BLOB and
|
|
59
|
+
// answers a BLOB column as an Array of byte numbers.
|
|
60
|
+
export function bindOf(value) {
|
|
61
|
+
if (value === null || typeof value !== "object" || typeof value.$blob !== "string") return value;
|
|
62
|
+
return Uint8Array.from(atob(value.$blob), (char) => char.charCodeAt(0)).buffer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isBytes = (value) => Array.isArray(value) || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
|
66
|
+
|
|
67
|
+
function bytesOf(value) {
|
|
68
|
+
if (Array.isArray(value)) return Uint8Array.from(value);
|
|
69
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
70
|
+
return new Uint8Array(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function base64Of(value) {
|
|
74
|
+
const bytes = bytesOf(value);
|
|
75
|
+
let binary = "";
|
|
76
|
+
for (let at = 0; at < bytes.length; at += 8192) binary += String.fromCharCode(...bytes.subarray(at, at + 8192));
|
|
77
|
+
return btoa(binary);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function rowOf(row) {
|
|
81
|
+
for (const column of Object.keys(row)) {
|
|
82
|
+
const value = row[column];
|
|
83
|
+
if (value !== null && typeof value === "object" && isBytes(value)) row[column] = { $blob: base64Of(value) };
|
|
84
|
+
}
|
|
85
|
+
return row;
|
|
86
|
+
}
|
|
13
87
|
|
|
14
88
|
// The wasm import module "edgeway_d1" (lib/edgeway/service/cloudflare/d1.rb). Each call
|
|
15
89
|
// answers the byte length of its JSON result; take() copies it out.
|
|
16
|
-
// query(binding, sql, binds) -> {"rows": [...]}
|
|
17
|
-
// execute(binding, sql, binds) -> {"changes": n, "last_insert_id": id}
|
|
18
|
-
//
|
|
90
|
+
// query(binding, sql, binds) -> {"rows": [...], "meta": META}
|
|
91
|
+
// execute(binding, sql, binds) -> {"changes": n, "last_insert_id": id, "meta": META}
|
|
92
|
+
// exec(binding, sql) -> {"count": n, "meta": META}
|
|
93
|
+
// (sql: statements separated by newlines, no binds; an error on a database with sessions)
|
|
94
|
+
// batch(binding, statements) -> {"results": [{"changes": n, "last_insert_id": id}, ...], "meta": META}
|
|
19
95
|
// (statements: [[sql, binds], ...], one atomic D1 batch)
|
|
20
|
-
//
|
|
21
|
-
|
|
96
|
+
// META is {"rows_read": n, "rows_written": n, "duration": ms, "served_by_region": "...",
|
|
97
|
+
// "served_by_primary": bool}; a batch's sums the statements'. exec's has only
|
|
98
|
+
// rows_read, rows_written and duration.
|
|
99
|
+
// An error is {"error": "message"} (no meta), with "unavailable": true when it is transient.
|
|
100
|
+
const SESSION_EXEC_ERROR = "exec() is not available on a database with sessions: true (a D1 session has no exec)";
|
|
101
|
+
|
|
102
|
+
export function d1Imports(env, getInstance, sessions = new D1Sessions(env)) {
|
|
22
103
|
const mem = () => new Uint8Array(getInstance().exports.memory.buffer);
|
|
23
104
|
const cstr = (p) => {
|
|
24
105
|
const b = mem();
|
|
@@ -29,36 +110,47 @@ export function d1Imports(env, getInstance) {
|
|
|
29
110
|
let pending = new Uint8Array(0);
|
|
30
111
|
|
|
31
112
|
// fn(db, ...pointers) answers the result object; a failure is {error}.
|
|
32
|
-
const suspending = (fn) => new WebAssembly.Suspending(async (bindingPtr, ...ptrs) => {
|
|
113
|
+
const suspending = (fn, { withoutSessions = false } = {}) => new WebAssembly.Suspending(async (bindingPtr, ...ptrs) => {
|
|
33
114
|
let res;
|
|
34
115
|
try {
|
|
35
116
|
const name = cstr(bindingPtr) || config.d1Binding;
|
|
36
|
-
|
|
117
|
+
if (withoutSessions && sessions.uses(name)) throw new Error(SESSION_EXEC_ERROR);
|
|
118
|
+
const db = sessions.database(name);
|
|
37
119
|
if (!db) throw new Error(`no D1 binding ${name}`);
|
|
38
120
|
res = await fn(db, ...ptrs);
|
|
39
121
|
} catch (e) {
|
|
40
|
-
|
|
122
|
+
const error = String(e?.message ?? e);
|
|
123
|
+
res = isTransientD1Error(error) ? { error, unavailable: true } : { error };
|
|
41
124
|
}
|
|
42
125
|
pending = new TextEncoder().encode(JSON.stringify(res));
|
|
43
126
|
return pending.length;
|
|
44
127
|
});
|
|
128
|
+
const prepare = (db, sql, binds) => db.prepare(sql).bind(...binds.map(bindOf));
|
|
45
129
|
const run = (fn) => suspending((db, sqlPtr, bindsPtr) =>
|
|
46
|
-
fn(
|
|
130
|
+
fn(prepare(db, cstr(sqlPtr), JSON.parse(cstr(bindsPtr)))));
|
|
47
131
|
|
|
48
132
|
return {
|
|
49
133
|
edgeway_d1: {
|
|
50
|
-
query: run(async (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return { changes: meta.changes ?? 0, last_insert_id: meta.last_row_id ?? 0 };
|
|
134
|
+
query: run(async (statement) => {
|
|
135
|
+
const result = await statement.all();
|
|
136
|
+
return { rows: result.results.map(rowOf), meta: metaOf(result.meta) };
|
|
54
137
|
}),
|
|
138
|
+
execute: run(async (statement) => {
|
|
139
|
+
const meta = (await statement.run()).meta;
|
|
140
|
+
return { changes: meta.changes ?? 0, last_insert_id: meta.last_row_id ?? 0, meta: metaOf(meta) };
|
|
141
|
+
}),
|
|
142
|
+
exec: suspending(async (db, sqlPtr) => {
|
|
143
|
+
const result = await db.exec(cstr(sqlPtr));
|
|
144
|
+
return { count: result.count ?? 0, meta: { rows_read: 0, rows_written: 0, duration: result.duration ?? 0 } };
|
|
145
|
+
}, { withoutSessions: true }),
|
|
55
146
|
// db.batch() is one transaction: a failing statement rejects it and
|
|
56
147
|
// none of the statements stay applied.
|
|
57
148
|
batch: suspending(async (db, statementsPtr) => {
|
|
58
149
|
const statements = JSON.parse(cstr(statementsPtr));
|
|
59
|
-
const results = await db.batch(statements.map(([sql, binds]) =>
|
|
150
|
+
const results = await db.batch(statements.map(([sql, binds]) => prepare(db, sql, binds)));
|
|
60
151
|
return {
|
|
61
|
-
results: results.map((
|
|
152
|
+
results: results.map((result) => ({ changes: result.meta.changes ?? 0, last_insert_id: result.meta.last_row_id ?? 0 })),
|
|
153
|
+
meta: sumMeta(results.map((result) => result.meta)),
|
|
62
154
|
};
|
|
63
155
|
}),
|
|
64
156
|
take(buf) {
|
|
@@ -90,14 +182,23 @@ export function varsOf(env) {
|
|
|
90
182
|
return vars;
|
|
91
183
|
}
|
|
92
184
|
|
|
185
|
+
export function corsOptions(sessionBindings) {
|
|
186
|
+
const names = sessionBindings.length > 0 ? [BOOKMARK_HEADER] : [];
|
|
187
|
+
return { exposeHeaders: names, allowHeaders: names };
|
|
188
|
+
}
|
|
189
|
+
|
|
93
190
|
// appModule: the WebAssembly.Module that `import app from "...app.wasm"` gives.
|
|
94
191
|
export function createWorker(appModule) {
|
|
95
192
|
return {
|
|
96
|
-
fetch: withCors((request, env) =>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
193
|
+
fetch: withCors(async (request, env) => {
|
|
194
|
+
const sessions = new D1Sessions(env, request, config.sessions ?? []);
|
|
195
|
+
const response = await runCgi(appModule, request, {
|
|
196
|
+
extraEnv: { EDGEWAY_ENV: config.env, ...varsOf(env) },
|
|
197
|
+
// Both modules are always supplied, whether or not the app uses them:
|
|
198
|
+
// the wasm instance needs every import it declares.
|
|
199
|
+
imports: (getInstance) => ({ ...d1Imports(env, getInstance, sessions), ...jwtImports(env, getInstance) }),
|
|
200
|
+
});
|
|
201
|
+
return sessions.annotate(response);
|
|
202
|
+
}, corsOptions(config.sessions ?? [])),
|
|
102
203
|
};
|
|
103
204
|
}
|
data/js/cors.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// status instead of an opaque CORS error. Nothing here is specific to a
|
|
5
5
|
// platform. Cookies are not supported: Access-Control-Allow-Credentials is
|
|
6
6
|
// never sent.
|
|
7
|
+
import { addVary, withHeaders } from "./headers.js";
|
|
8
|
+
|
|
7
9
|
const DEFAULT_HEADERS = "Authorization, Content-Type";
|
|
8
10
|
const DEFAULT_MAX_AGE = "600";
|
|
9
11
|
const METHODS = "GET, HEAD, POST, PUT, PATCH, DELETE";
|
|
@@ -15,17 +17,17 @@ function json(status, body, headers = {}) {
|
|
|
15
17
|
// The settings from the Worker's variables, or null when CORS is off.
|
|
16
18
|
// A bad CORS_MAX_AGE is { error }: like JWT, a misconfiguration is a 500, not
|
|
17
19
|
// a silent default.
|
|
18
|
-
function settings(env) {
|
|
19
|
-
const list = (env.CORS_ORIGINS ?? "").split(",").map((
|
|
20
|
+
function settings(env, { exposeHeaders, allowHeaders }) {
|
|
21
|
+
const list = (env.CORS_ORIGINS ?? "").split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
20
22
|
if (list.length === 0) return null;
|
|
21
23
|
const maxAge = (env.CORS_MAX_AGE ?? "").trim() || DEFAULT_MAX_AGE;
|
|
22
24
|
if (!/^\d+$/.test(maxAge)) return { error: `CORS_MAX_AGE must be a non-negative integer, got "${env.CORS_MAX_AGE}"` };
|
|
23
25
|
return {
|
|
24
26
|
any: list.includes("*"),
|
|
25
27
|
origins: new Set(list),
|
|
26
|
-
headers: (env.CORS_HEADERS ?? "").trim() || DEFAULT_HEADERS,
|
|
28
|
+
headers: withHeaders((env.CORS_HEADERS ?? "").trim() || DEFAULT_HEADERS, ...allowHeaders),
|
|
27
29
|
maxAge,
|
|
28
|
-
expose: (env.CORS_EXPOSE_HEADERS
|
|
30
|
+
expose: withHeaders(env.CORS_EXPOSE_HEADERS, ...exposeHeaders),
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -36,20 +38,17 @@ function allowed(cfg, origin) {
|
|
|
36
38
|
return cfg.any || cfg.origins.has(origin);
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (have.includes("*")) return;
|
|
43
|
-
for (const n of names) {
|
|
44
|
-
if (!have.some((h) => h.toLowerCase() === n.toLowerCase())) have.push(n);
|
|
45
|
-
}
|
|
46
|
-
headers.set("Vary", have.join(", "));
|
|
41
|
+
function exposeOn(headers, expose) {
|
|
42
|
+
const merged = withHeaders(headers.get("Access-Control-Expose-Headers"), ...expose.split(", ").filter(Boolean));
|
|
43
|
+
if (merged) headers.set("Access-Control-Expose-Headers", merged);
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
// Wraps a fetch handler (request, env, ctx) -> Response.
|
|
50
|
-
|
|
46
|
+
// Wraps a fetch handler (request, env, ctx) -> Response. exposeHeaders and
|
|
47
|
+
// allowHeaders are names the platform layer always adds to the exposed and the
|
|
48
|
+
// preflight-allowed headers.
|
|
49
|
+
export function withCors(handler, { exposeHeaders = [], allowHeaders = [] } = {}) {
|
|
51
50
|
return async (request, env, ctx) => {
|
|
52
|
-
const cfg = settings(env);
|
|
51
|
+
const cfg = settings(env, { exposeHeaders, allowHeaders });
|
|
53
52
|
if (!cfg) return handler(request, env, ctx);
|
|
54
53
|
if (cfg.error) {
|
|
55
54
|
console.error(cfg.error);
|
|
@@ -79,7 +78,7 @@ export function withCors(handler) {
|
|
|
79
78
|
const out = new Response(res.body, res);
|
|
80
79
|
if (ok) {
|
|
81
80
|
out.headers.set("Access-Control-Allow-Origin", origin);
|
|
82
|
-
|
|
81
|
+
exposeOn(out.headers, cfg.expose);
|
|
83
82
|
}
|
|
84
83
|
addVary(out.headers, "Origin");
|
|
85
84
|
return out;
|
data/js/headers.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const listOf = (value) => (value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
2
|
+
|
|
3
|
+
export function withHeaders(list, ...names) {
|
|
4
|
+
const have = listOf(list);
|
|
5
|
+
for (const name of names) {
|
|
6
|
+
if (!have.some((existing) => existing.toLowerCase() === name.toLowerCase())) have.push(name);
|
|
7
|
+
}
|
|
8
|
+
return have.join(", ");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A Vary of * already covers every header.
|
|
12
|
+
export function addVary(headers, ...names) {
|
|
13
|
+
const have = listOf(headers.get("Vary"));
|
|
14
|
+
if (have.includes("*")) return;
|
|
15
|
+
headers.set("Vary", withHeaders(have.join(", "), ...names));
|
|
16
|
+
}
|
data/js/jwt.js
CHANGED
|
@@ -245,7 +245,7 @@ export function jwtVerifier(env, { fetch = globalThis.fetch, now = Date.now, cac
|
|
|
245
245
|
// verify(token) -> {"header": {...}, "payload": {...}}
|
|
246
246
|
// | {"error": "message"} the token is bad
|
|
247
247
|
// | {"error": "message", "unavailable": true} it can't be checked
|
|
248
|
-
// Like
|
|
248
|
+
// Like the other imports, a call answers the byte length of the result and take() copies it
|
|
249
249
|
// out; verify is wrapped in WebAssembly.Suspending (the WebCrypto and fetch
|
|
250
250
|
// Promises are awaited with the wasm stack suspended).
|
|
251
251
|
export function jwtImports(env, getInstance, options = {}) {
|
data/lib/edgeway/application.rb
CHANGED
|
@@ -38,6 +38,11 @@ module Edgeway
|
|
|
38
38
|
# The message names the table and columns: the log only.
|
|
39
39
|
Edgeway.logger.info "409 #{e.message}"
|
|
40
40
|
message(409, "record not unique")
|
|
41
|
+
elsif e.is_a?(DatabaseUnavailable)
|
|
42
|
+
Edgeway.logger.error "503 #{e.message}"
|
|
43
|
+
response = message(503, "database unavailable")
|
|
44
|
+
response.headers["Retry-After"] = "1"
|
|
45
|
+
response
|
|
41
46
|
elsif e.is_a?(RoutingError) || e.is_a?(RecordNotFound)
|
|
42
47
|
# The message can hold the path or an id from the request: the log only.
|
|
43
48
|
Edgeway.logger.info "404 #{e.message}"
|
|
@@ -58,9 +63,7 @@ module Edgeway
|
|
|
58
63
|
end
|
|
59
64
|
|
|
60
65
|
def message(status, text)
|
|
61
|
-
|
|
62
|
-
# Spinel refuses this String as it is, or emits C that doesn't compile.
|
|
63
|
-
Controller::Rendering.json_response(status, JSON.generate({ "error" => text.dup }))
|
|
66
|
+
Controller::Rendering.json_response(status, JSON.generate({ "error" => text }))
|
|
64
67
|
end
|
|
65
68
|
end
|
|
66
69
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "database_config"
|
|
4
|
+
|
|
5
|
+
module Edgeway
|
|
6
|
+
module CLI
|
|
7
|
+
class Build
|
|
8
|
+
class AppConfig
|
|
9
|
+
# adapter: => the file build/main.rb requires for it.
|
|
10
|
+
ADAPTERS = {
|
|
11
|
+
"d1" => "edgeway/service/cloudflare/d1",
|
|
12
|
+
"memory" => "edgeway/db/memory"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :env, :adapter, :binding_name, :databases, :sessions
|
|
16
|
+
|
|
17
|
+
def self.load(root, env)
|
|
18
|
+
config = DatabaseConfig.new(root)
|
|
19
|
+
section = config.section(env)
|
|
20
|
+
databases = config.databases_in(section, env)
|
|
21
|
+
sessions = config.session_bindings(section, env)
|
|
22
|
+
new(env, section["adapter"].to_s, config.primary_binding(section), databases, sessions)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(env, adapter, binding_name, databases = {}, sessions = [])
|
|
26
|
+
raise Error, "unknown database adapter #{adapter.inspect} (one of #{ADAPTERS.keys.join(", ")})" unless ADAPTERS.key?(adapter)
|
|
27
|
+
|
|
28
|
+
@env = env
|
|
29
|
+
@adapter = adapter
|
|
30
|
+
@binding_name = binding_name
|
|
31
|
+
@databases = databases
|
|
32
|
+
@sessions = adapter == "d1" ? sessions : []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def adapter_file = ADAPTERS.fetch(@adapter)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Edgeway
|
|
4
|
+
module CLI
|
|
5
|
+
class Build
|
|
6
|
+
class AppFiles
|
|
7
|
+
def initialize(root)
|
|
8
|
+
@root = root
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def require_lines(views: nil)
|
|
12
|
+
lines = []
|
|
13
|
+
app = File.join(@root, "app")
|
|
14
|
+
dirs = Dir.exist?(app) ? Dir.children(app).sort.select { |d| File.directory?(File.join(app, d)) } : []
|
|
15
|
+
dirs.each do |d|
|
|
16
|
+
next if %w[models controllers].include?(d)
|
|
17
|
+
|
|
18
|
+
lines.concat(requires("app/#{d}"))
|
|
19
|
+
end
|
|
20
|
+
lines.concat(requires("app/models"))
|
|
21
|
+
lines.concat(requires("app/controllers"))
|
|
22
|
+
lines << "require_relative #{views.dump}" if views
|
|
23
|
+
lines << 'require_relative "../config/routes"'
|
|
24
|
+
lines
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# require_relative lines for the .rb files under dir, application_*.rb first.
|
|
28
|
+
def requires(dir)
|
|
29
|
+
full = File.join(@root, dir)
|
|
30
|
+
return [] unless Dir.exist?(full)
|
|
31
|
+
|
|
32
|
+
files = Dir.glob("**/*.rb", base: full).map { |f| File.join(dir, f) }
|
|
33
|
+
first, rest = files.partition { |f| File.basename(f).start_with?("application_") }
|
|
34
|
+
# String#dump quotes a path with spaces, quotes or "#{" safely.
|
|
35
|
+
(first.sort + rest.sort).map { |f| "require_relative #{"../#{f.delete_suffix(".rb")}".dump}" }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Edgeway
|
|
4
|
+
module CLI
|
|
5
|
+
class Build
|
|
6
|
+
class DatabaseConfig
|
|
7
|
+
DATABASE_NAME = /\A[a-z_][a-z0-9_]*\z/
|
|
8
|
+
|
|
9
|
+
def initialize(root)
|
|
10
|
+
@root = root
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def section(env)
|
|
14
|
+
section = sections[env]
|
|
15
|
+
raise Error, "config/database.yml has no #{env} section" unless section.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
section
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def databases_in(section, env)
|
|
21
|
+
entries = section["databases"] || {}
|
|
22
|
+
raise Error, "config/database.yml: databases in #{env} must be a Hash" unless entries.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
entries.to_h do |name, entry|
|
|
25
|
+
name = name.to_s
|
|
26
|
+
[name, database_binding(name, entry, env)]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def primary_binding(section) = (section["binding"] || "DB").to_s
|
|
31
|
+
|
|
32
|
+
def session_bindings(section, env)
|
|
33
|
+
flags = {}
|
|
34
|
+
record_sessions(flags, "the #{env} section", primary_binding(section), section, "the #{env} section")
|
|
35
|
+
(section["databases"] || {}).each do |name, entry|
|
|
36
|
+
record_sessions(flags, "databases.#{name}", entry["binding"].to_s, entry, "databases.#{name} in #{env}")
|
|
37
|
+
end
|
|
38
|
+
flags.select { |_binding, (_label, enabled)| enabled }.keys
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def names
|
|
42
|
+
names = []
|
|
43
|
+
return names unless File.exist?(path)
|
|
44
|
+
|
|
45
|
+
sections.each do |env, section|
|
|
46
|
+
next unless section.is_a?(Hash)
|
|
47
|
+
databases_in(section, env).each_key { |name| names << name unless names.include?(name) }
|
|
48
|
+
end
|
|
49
|
+
names
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def path = File.join(@root, "config", "database.yml")
|
|
55
|
+
|
|
56
|
+
def sections
|
|
57
|
+
raise Error, "no config/database.yml in #{@root} (is this an Edgeway app?)" unless File.exist?(path)
|
|
58
|
+
|
|
59
|
+
YAML.safe_load_file(path) || {}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def record_sessions(flags, label, binding_name, entry, where)
|
|
63
|
+
enabled = sessions?(entry, where)
|
|
64
|
+
known_label, known = flags[binding_name]
|
|
65
|
+
if known_label && known != enabled
|
|
66
|
+
raise Error, "config/database.yml: #{known_label} and #{label} share binding #{binding_name} but differ in sessions"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
flags[binding_name] = [label, enabled]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def sessions?(entry, where)
|
|
73
|
+
return false unless entry.is_a?(Hash) && entry.key?("sessions")
|
|
74
|
+
return entry["sessions"] if [true, false].include?(entry["sessions"])
|
|
75
|
+
|
|
76
|
+
raise Error, "config/database.yml: sessions in #{where} must be true or false"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def database_binding(name, entry, env)
|
|
80
|
+
unless name.match?(DATABASE_NAME) && name != "primary"
|
|
81
|
+
raise Error, "config/database.yml: bad database name #{name.inspect} in #{env} (lowercase letters, digits and _, and not primary)"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
binding_name = entry.is_a?(Hash) ? entry["binding"].to_s : ""
|
|
85
|
+
raise Error, "config/database.yml: databases.#{name} in #{env} has no binding:" if binding_name.empty?
|
|
86
|
+
|
|
87
|
+
binding_name
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|