rosefc 0.0.0-stage → 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.
@@ -0,0 +1,53 @@
1
+ // The conditional-request validator a dynamic response answers with.
2
+ //
3
+ // A server-side cache key can name a representation perfectly and still hand
4
+ // the client the wrong answer, because the client does not compare keys - it
5
+ // compares validators. The prod server used to derive a non-HTML entry's
6
+ // validator from `raw.length` plus the cookie (BUG.md AM): an anonymous
7
+ // reader's cookie is the empty string, so the validator only moved when the
8
+ // byte count moved. Two equal-length representations with different bytes - a
9
+ // same-length typo fix, a re-rendered feed of the same size - therefore shared
10
+ // one validator, and every conditional request against the newer entry answered
11
+ // 304 with the older bytes: a false 304 that survives until the length changes.
12
+ //
13
+ // The rule that closes it is content-derived and cheap: hash what is about to
14
+ // go on the wire. sha1 is not a collision-resistant signature any more, but a
15
+ // validator only needs the property that a byte change flips it, and RFC 9110
16
+ // lets a weak validator (`W/"..."`) be answered with 200 whenever the client's
17
+ // copy is not byte-equivalent. sha1 over a render that already cost a route
18
+ // lookup and a serialization is noise on the budget.
19
+ //
20
+ // Ownership still belongs to the origin server: an app that publishes its own
21
+ // `ETag` keeps it. The framework only synthesizes one where the response has
22
+ // none, so an application-side validator (a database version stamp, a content
23
+ // hash of the pre-serialized row) is never silently replaced underneath a
24
+ // client that cached it.
25
+ import { createHash } from 'node:crypto';
26
+
27
+ /** A weak, content-derived validator for the exact bytes going on the wire. */
28
+ export function contentEtag(raw: Buffer): string {
29
+ return 'W/"' + createHash('sha1').update(raw).digest('base64url').slice(0, 24) + '"';
30
+ }
31
+
32
+ /** The validator this response carries: the app's own if it published one,
33
+ * otherwise the hash of its body. Both the exit that answers the request and
34
+ * the cache entry that replays it must agree, or a hot replay would mint a
35
+ * validator the cold render never showed. */
36
+ export function responseEtag(response: { headers: Headers }, raw: Buffer): string {
37
+ return response.headers.get('etag') ?? contentEtag(raw);
38
+ }
39
+
40
+ /** RFC 9110 §13.1.2: `If-None-Match` is a list, `*` matches any current
41
+ * representation, and the comparison is weak - the `W/` prefix is not part of
42
+ * the identity, so a client that echoed a strong tag still revalidates. */
43
+ export function ifNoneMatch(header: string | string[] | undefined | null, etag: string): boolean {
44
+ if (!header) return false;
45
+ const bare = etag.startsWith('W/') ? etag.slice(2) : etag;
46
+ for (const part of (Array.isArray(header) ? header.join(',') : header).split(',')) {
47
+ const token = part.trim();
48
+ if (!token) continue;
49
+ if (token === '*') return true;
50
+ if ((token.startsWith('W/') ? token.slice(2) : token) === bare) return true;
51
+ }
52
+ return false;
53
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,9 @@
1
+ export function fnv1a32(input: Uint8Array | string): string {
2
+ let h = 0x811c9dc5;
3
+ const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : input;
4
+ for (let i = 0; i < bytes.length; i++) {
5
+ h ^= bytes[i];
6
+ h = Math.imul(h, 0x01000193);
7
+ }
8
+ return (h >>> 0).toString(36);
9
+ }
@@ -0,0 +1,166 @@
1
+ // src/runtime/db.ts - the database helper tier for pages/api/*.rose handlers.
2
+ //
3
+ // Rose ships no database driver: pg, mysql2 and node:sqlite are three
4
+ // incompatible shapes, and none of them belongs in a runtime whose entire
5
+ // published dependency list is esbuild. So the app supplies ONE object that
6
+ // can run a query - the `Driver` below - and every handler talks to the typed
7
+ // helpers in this file. Swapping SQLite for Postgres is then a one-file
8
+ // change (the driver), not a rewrite of twenty routes.
9
+ //
10
+ // WIRING (once, at boot, in a server-only place - src/pages/_middleware.rose,
11
+ // or a module that file imports; never a page: a page script ships to the
12
+ // browser). Register there, and handlers call q()/q1()/tx() with no driver
13
+ // import at all.
14
+ //
15
+ // // Postgres. connect() is what makes tx() correct: a pool hands out a
16
+ // // different connection per query, so BEGIN/COMMIT on `pool.query` would
17
+ // // run on different sockets. One connection for the whole transaction.
18
+ // import { Pool } from 'pg';
19
+ // import { registerDb } from '../../src/runtime/db'; // flat-build-relative
20
+ // const pool = new Pool({ connectionString: process.env.DATABASE_URL });
21
+ // registerDb({
22
+ // query: (sql, params) => pool.query(sql, params as any[]),
23
+ // async connect() {
24
+ // const c = await pool.connect();
25
+ // return { query: (sql, params) => c.query(sql, params as any[]), release: () => c.release() };
26
+ // },
27
+ // });
28
+ //
29
+ // // mysql2/promise is the same two lines over `pool.execute`.
30
+ //
31
+ // // SQLite via node:sqlite: no connect() - one connection IS the pool, so
32
+ // // tx() sends BEGIN/COMMIT/ROLLBACK through query() on it.
33
+ // const sqlite = new DatabaseSync('blog.db');
34
+ // registerDb({ query: async (sql, params) => ({ rows: sqlite.prepare(sql).all(...(params ?? [])) }) });
35
+ //
36
+ // Then, in any handler:
37
+ //
38
+ // const posts = await q<PostRow>('SELECT * FROM posts ORDER BY id DESC');
39
+ // const post = await q1<PostRow>('SELECT * FROM posts WHERE id = ?', [id]); // null when absent
40
+ // await tx(async (t) => { // all or nothing
41
+ // await t.q1('INSERT INTO posts (title) VALUES (?) RETURNING id', [title]);
42
+ // await t.q('UPDATE counters SET n = n + 1 WHERE k = ?', ['posts']);
43
+ // });
44
+ //
45
+ // A handler that returns q()'s rows is enough on its own: an exported API
46
+ // function's plain object return value is serialized as JSON.
47
+ //
48
+ // The driver lives on globalThis, not in a module-level `let`, because the
49
+ // runtime is bundled once per page (sliced and per-page inline bundles each
50
+ // carry their own copy of this module) - a module-level variable would be
51
+ // per-copy, so a route would see "no driver registered" from a different
52
+ // bundle's registration. Same reason the rest of the server runtime publishes
53
+ // itself through __rosefdAuth / __rosefdKvApi / __rosefdExtras.
54
+ //
55
+ // Zero dependencies: no import statement in this file at all.
56
+
57
+ /** The one thing an app must supply. `query` is async even for a sync driver. */
58
+ export interface Driver {
59
+ query(sql: string, params?: readonly unknown[]): Promise<{ rows: any[] }>;
60
+ /**
61
+ * Optional: a dedicated connection for a transaction, released when tx()
62
+ * ends. Required for a POOL (pg, mysql2) - without it tx() runs BEGIN and
63
+ * COMMIT through query() and two different sockets would each hold half a
64
+ * transaction.
65
+ */
66
+ connect?(): Promise<Conn>;
67
+ }
68
+
69
+ /** What connect() returns: the connection, plus the release that returns it. */
70
+ export interface Conn extends Driver {
71
+ release(): void | Promise<void>;
72
+ }
73
+
74
+ /** The query helpers. What tx() hands its callback, so it cannot nest. */
75
+ export interface Queries {
76
+ /** Every matching row. `[]` when none match. */
77
+ q<T = any>(sql: string, params?: readonly unknown[]): Promise<T[]>;
78
+ /** The first matching row, or null. Rows are untyped - you name the shape. */
79
+ q1<T = any>(sql: string, params?: readonly unknown[]): Promise<T | null>;
80
+ }
81
+
82
+ /** The registered database: the query helpers plus transactions. */
83
+ export interface Db extends Queries {
84
+ tx<T>(fn: (t: Queries) => Promise<T> | T): Promise<T>;
85
+ }
86
+
87
+ const KEY = '__rosefdDb';
88
+
89
+ const slot = () => globalThis as unknown as Record<string, Driver | undefined>;
90
+
91
+ /**
92
+ * Publish the app's driver. Call once at boot, before the first request;
93
+ * calling again (a dev hot reload) simply replaces it.
94
+ */
95
+ export function registerDb(driver: Driver): void {
96
+ slot()[KEY] = driver;
97
+ }
98
+
99
+ function requireDriver(): Driver {
100
+ const driver = slot()[KEY];
101
+ if (!driver) {
102
+ throw new Error(
103
+ 'rosefd/db: no database driver registered - call registerDb({ query }) once at boot ' +
104
+ '(src/pages/_middleware.rose, or a module it imports) before any handler queries',
105
+ );
106
+ }
107
+ return driver;
108
+ }
109
+
110
+ const on = (run: Driver): Queries => ({
111
+ q: async <T>(sql: string, params?: readonly unknown[]): Promise<T[]> => (await run.query(sql, params)).rows as T[],
112
+ q1: async <T>(sql: string, params?: readonly unknown[]): Promise<T | null> => {
113
+ const { rows } = await run.query(sql, params);
114
+ return (rows[0] ?? null) as T | null;
115
+ },
116
+ });
117
+
118
+ // q/q1 are `async` rather than plain `Promise`-returning wrappers so a missing
119
+ // driver surfaces as a REJECTED promise: a handler written `export function
120
+ // GET()` (not async) can still await/.catch it instead of crashing at call
121
+ // time on a synchronous throw.
122
+ /** Every matching row, on the registered driver. */
123
+ export async function q<T = any>(sql: string, params?: readonly unknown[]): Promise<T[]> {
124
+ return on(requireDriver()).q<T>(sql, params);
125
+ }
126
+
127
+ /** The first matching row, or null, on the registered driver. */
128
+ export async function q1<T = any>(sql: string, params?: readonly unknown[]): Promise<T | null> {
129
+ return on(requireDriver()).q1<T>(sql, params);
130
+ }
131
+
132
+ /**
133
+ * Run `fn` inside one transaction: BEGIN, then COMMIT on success, ROLLBACK and
134
+ * rethrow on failure. The callback's handle is connection-scoped (queries go
135
+ * to the same connection the BEGIN went to) and deliberately has no tx(), so a
136
+ * nested transaction cannot deadlock two connections against each other.
137
+ */
138
+ export async function tx<T>(fn: (t: Queries) => Promise<T> | T): Promise<T> {
139
+ const driver = requireDriver();
140
+ const conn = driver.connect ? await driver.connect() : null;
141
+ const run = conn ?? driver;
142
+ try {
143
+ await run.query('BEGIN');
144
+ const out = await fn(on(run));
145
+ await run.query('COMMIT');
146
+ return out;
147
+ } catch (err) {
148
+ // The original failure is the one worth reporting; a rollback that also
149
+ // fails (a dropped socket) must not replace it.
150
+ try {
151
+ await run.query('ROLLBACK');
152
+ } catch { /* the error thrown below is the truth */ }
153
+ throw err;
154
+ } finally {
155
+ if (conn) await conn.release();
156
+ }
157
+ }
158
+
159
+ /** The registered database, for handlers that would rather hold an object. */
160
+ export function db(): Db {
161
+ return {
162
+ q: (sql, params) => q(sql, params),
163
+ q1: (sql, params) => q1(sql, params),
164
+ tx: (fn) => tx(fn),
165
+ };
166
+ }