@human-synthesis/norns 0.0.6 → 0.0.7
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/README.md +117 -6
- package/bin/norns.js +173 -4
- package/package.json +9 -4
- package/src/auto-import.js +638 -0
- package/src/config.js +48 -3
- package/src/index.js +2 -1
- package/src/migrate.js +212 -0
- package/src/server/boot.js +74 -0
- package/src/server/container.js +175 -0
- package/src/server/db.js +131 -0
- package/src/server/handle/context.js +24 -0
- package/src/server/handle/error.js +18 -0
- package/src/server/index.js +9 -0
- package/src/server/page.js +107 -0
- package/src/server/route.js +114 -0
- package/src/server/scope.js +50 -0
- package/src/server/validate.js +60 -0
- package/src/vite.js +24 -17
package/src/config.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { nornsPreprocess } from '@human-synthesis/norns-core/preprocess';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -5,7 +7,13 @@ import { nornsPreprocess } from '@human-synthesis/norns-core/preprocess';
|
|
|
5
7
|
*
|
|
6
8
|
* Defaults:
|
|
7
9
|
* - `extensions: ['.svelte', '.n']` — both vanilla and Norns components
|
|
8
|
-
* - `kit.moduleExtensions: ['.js', '.ts', '.c']` — Kit special files
|
|
10
|
+
* - `kit.moduleExtensions: ['.js', '.ts', '.c', '.civet']` — Kit special files
|
|
11
|
+
* (`+page.c`, `+page.civet`, etc.)
|
|
12
|
+
* - `kit.files.hooks.server` — set to `src/hooks.server.{c,civet}` if either
|
|
13
|
+
* file exists. SvelteKit's upstream `resolve_entry` only searches `.js` /
|
|
14
|
+
* `.ts` for hooks (it doesn't honor `moduleExtensions`), so the explicit
|
|
15
|
+
* path is the non-invasive way to make `.c`/`.civet` hooks discoverable.
|
|
16
|
+
* Same for the client and universal counterparts.
|
|
9
17
|
* - `preprocess: nornsPreprocess()` — Coffee + Pug + rune fusion + auto-close
|
|
10
18
|
*
|
|
11
19
|
* Spread your own overrides at the call site to extend or replace defaults.
|
|
@@ -20,13 +28,50 @@ export function nornsConfig(overrides = {}) {
|
|
|
20
28
|
extensions: extensionsOverride,
|
|
21
29
|
...rest
|
|
22
30
|
} = overrides;
|
|
31
|
+
|
|
32
|
+
const cwd = process.cwd();
|
|
33
|
+
const userFiles = kitOverrides.files ?? {};
|
|
34
|
+
const userHooks = userFiles.hooks ?? {};
|
|
35
|
+
|
|
36
|
+
const hooks = {
|
|
37
|
+
...userHooks,
|
|
38
|
+
server: userHooks.server ?? findHook(cwd, ['src/hooks.server.c', 'src/hooks.server.civet']),
|
|
39
|
+
client: userHooks.client ?? findHook(cwd, ['src/hooks.client.c', 'src/hooks.client.civet']),
|
|
40
|
+
universal: userHooks.universal ?? findHook(cwd, ['src/hooks.c', 'src/hooks.civet'])
|
|
41
|
+
};
|
|
42
|
+
// Drop keys whose value is undefined so SvelteKit applies its defaults
|
|
43
|
+
for (const k of /** @type {const} */ (['server', 'client', 'universal'])) {
|
|
44
|
+
if (hooks[k] === undefined) delete hooks[k];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { files: _ignoredUserFiles, ...kitRest } = kitOverrides;
|
|
48
|
+
|
|
23
49
|
return {
|
|
24
50
|
extensions: extensionsOverride ?? ['.svelte', '.n'],
|
|
25
51
|
preprocess: preprocessOverride ?? nornsPreprocess(),
|
|
26
52
|
kit: {
|
|
27
|
-
moduleExtensions: ['.js', '.ts', '.c'],
|
|
28
|
-
...
|
|
53
|
+
moduleExtensions: ['.js', '.ts', '.c', '.civet'],
|
|
54
|
+
...kitRest,
|
|
55
|
+
files: {
|
|
56
|
+
...userFiles,
|
|
57
|
+
hooks
|
|
58
|
+
}
|
|
29
59
|
},
|
|
30
60
|
...rest
|
|
31
61
|
};
|
|
32
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Return the first relative path whose file exists, or `undefined` so
|
|
66
|
+
* SvelteKit falls back to its default `.js` / `.ts` resolution.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} cwd
|
|
69
|
+
* @param {string[]} candidates
|
|
70
|
+
* @returns {string | undefined}
|
|
71
|
+
*/
|
|
72
|
+
function findHook(cwd, candidates) {
|
|
73
|
+
for (const rel of candidates) {
|
|
74
|
+
if (existsSync(join(cwd, rel))) return rel;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
package/src/index.js
CHANGED
package/src/migrate.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration discovery + applier for the `norns migrate` CLI. Pure functions —
|
|
3
|
+
* `bin/norns.js` is a thin shell over these.
|
|
4
|
+
*
|
|
5
|
+
* Layout convention:
|
|
6
|
+
* <project>/migrations/<feature>/<timestamp>_<slug>.sql
|
|
7
|
+
*
|
|
8
|
+
* Migrations live at the project root, OUTSIDE `src/lib/`, organised per
|
|
9
|
+
* feature. They aren't application code — they're operational artifacts that
|
|
10
|
+
* tooling (this CLI, wrangler for D1) reads. Keeping them out of `src/lib`
|
|
11
|
+
* also keeps SvelteKit's bundler from ever trying to ship them.
|
|
12
|
+
*
|
|
13
|
+
* v1 supports SQLite via `better-sqlite3`. Postgres/libSQL come later;
|
|
14
|
+
* Cloudflare D1 is intentionally out of scope here — use
|
|
15
|
+
* `wrangler d1 migrations apply <db>` for D1 deploys.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readdirSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reserved names under `src/lib/`. These are treated as utility folders, not
|
|
24
|
+
* features. Anything starting with `_` is also reserved.
|
|
25
|
+
*/
|
|
26
|
+
export const RESERVED_LIB_DIRS = new Set(['server', 'components', 'utils']);
|
|
27
|
+
|
|
28
|
+
export const MIGRATION_TABLE = 'norns_migrations';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* List feature folder names under `src/lib/`. A folder is a feature iff it
|
|
32
|
+
* contains `server/module.c`.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} libDir
|
|
35
|
+
* @returns {string[]}
|
|
36
|
+
*/
|
|
37
|
+
export function listFeatures(libDir) {
|
|
38
|
+
if (!existsSync(libDir)) return [];
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const name of readdirSync(libDir)) {
|
|
41
|
+
if (name.startsWith('_')) continue;
|
|
42
|
+
if (RESERVED_LIB_DIRS.has(name)) continue;
|
|
43
|
+
const featureDir = join(libDir, name);
|
|
44
|
+
try {
|
|
45
|
+
if (!statSync(featureDir).isDirectory()) continue;
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const modulePath = join(featureDir, 'server', 'module.c');
|
|
50
|
+
if (existsSync(modulePath)) out.push(name);
|
|
51
|
+
}
|
|
52
|
+
out.sort();
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @typedef {{ feature: string, file: string, path: string, id: string }} Migration */
|
|
57
|
+
|
|
58
|
+
/** @param {string} cwd */
|
|
59
|
+
export function migrationsRoot(cwd) {
|
|
60
|
+
return join(cwd, 'migrations');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Scan `<cwd>/migrations/<feature>/*.sql` across all features. Sorted by
|
|
65
|
+
* filename first (so timestamp-prefixed files apply chronologically across
|
|
66
|
+
* features) and feature name as a tiebreaker.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} cwd
|
|
69
|
+
* @returns {Migration[]}
|
|
70
|
+
*/
|
|
71
|
+
export function listMigrations(cwd) {
|
|
72
|
+
const root = migrationsRoot(cwd);
|
|
73
|
+
if (!existsSync(root)) return [];
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const feature of readdirSync(root)) {
|
|
76
|
+
const featDir = join(root, feature);
|
|
77
|
+
try {
|
|
78
|
+
if (!statSync(featDir).isDirectory()) continue;
|
|
79
|
+
} catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
for (const file of readdirSync(featDir)) {
|
|
83
|
+
if (!file.endsWith('.sql')) continue;
|
|
84
|
+
out.push({
|
|
85
|
+
feature,
|
|
86
|
+
file,
|
|
87
|
+
path: join(featDir, file),
|
|
88
|
+
id: `${feature}/${file.replace(/\.sql$/, '')}`
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
out.sort((a, b) => {
|
|
93
|
+
const c = a.file.localeCompare(b.file);
|
|
94
|
+
return c !== 0 ? c : a.feature.localeCompare(b.feature);
|
|
95
|
+
});
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve `DATABASE_URL` to a connection. v1: only `file:` (SQLite via
|
|
101
|
+
* better-sqlite3). Defaults to `file:./data/app.db` if unset.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} cwd
|
|
104
|
+
* @returns {{ kind: 'sqlite', path: string }}
|
|
105
|
+
*/
|
|
106
|
+
export function resolveDatabaseUrl(cwd) {
|
|
107
|
+
const url = process.env.DATABASE_URL || `file:${join(cwd, 'data', 'app.db')}`;
|
|
108
|
+
if (url.startsWith('file:')) return { kind: 'sqlite', path: url.slice(5) };
|
|
109
|
+
const scheme = url.split('://')[0];
|
|
110
|
+
throw new Error(
|
|
111
|
+
`norns migrate: only SQLite (file:...) is supported in v1; got "${scheme}://...".\n` +
|
|
112
|
+
' For Cloudflare D1, use `wrangler d1 migrations apply <db>`.\n' +
|
|
113
|
+
' Postgres/libSQL via the CLI are not yet wired.'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Open a better-sqlite3 db at `path` and ensure the migration tracking table
|
|
119
|
+
* exists.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} cwd directory whose `package.json` is used to resolve
|
|
122
|
+
* better-sqlite3 from the consumer's node_modules
|
|
123
|
+
* @param {string} path SQLite file path
|
|
124
|
+
* @param {{ requireFrom?: string | URL }} [opts] override the require base
|
|
125
|
+
* (used by tests)
|
|
126
|
+
* @returns {any}
|
|
127
|
+
*/
|
|
128
|
+
export function openSqliteDb(cwd, path, opts = {}) {
|
|
129
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
130
|
+
const require = createRequire(opts.requireFrom ?? join(cwd, 'package.json'));
|
|
131
|
+
let Database;
|
|
132
|
+
try {
|
|
133
|
+
Database = require('better-sqlite3');
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error(
|
|
136
|
+
'norns migrate: `better-sqlite3` is not installed in this app. Run: bun add better-sqlite3'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const db = new Database(path);
|
|
140
|
+
db.pragma('journal_mode = WAL');
|
|
141
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${MIGRATION_TABLE} (
|
|
142
|
+
id TEXT PRIMARY KEY,
|
|
143
|
+
applied_at INTEGER NOT NULL
|
|
144
|
+
)`);
|
|
145
|
+
return db;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {any} db
|
|
150
|
+
* @returns {Set<string>}
|
|
151
|
+
*/
|
|
152
|
+
export function getApplied(db) {
|
|
153
|
+
const rows = db.prepare(`SELECT id FROM ${MIGRATION_TABLE}`).all();
|
|
154
|
+
return new Set(rows.map((r) => r.id));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Apply pending migrations to `db`. Returns the list of ids applied.
|
|
159
|
+
*
|
|
160
|
+
* @param {any} db
|
|
161
|
+
* @param {Migration[]} pending
|
|
162
|
+
* @returns {string[]}
|
|
163
|
+
*/
|
|
164
|
+
export function applyMigrations(db, pending) {
|
|
165
|
+
const insert = db.prepare(`INSERT INTO ${MIGRATION_TABLE} (id, applied_at) VALUES (?, ?)`);
|
|
166
|
+
const applied = [];
|
|
167
|
+
for (const m of pending) {
|
|
168
|
+
const sql = readFileSync(m.path, 'utf8');
|
|
169
|
+
const tx = db.transaction(() => {
|
|
170
|
+
db.exec(sql);
|
|
171
|
+
insert.run(m.id, Date.now());
|
|
172
|
+
});
|
|
173
|
+
tx();
|
|
174
|
+
applied.push(m.id);
|
|
175
|
+
}
|
|
176
|
+
return applied;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Scaffold a new migration file at `<cwd>/migrations/<feature>/<ts>_<slug>.sql`.
|
|
181
|
+
*
|
|
182
|
+
* No filesystem check on the feature name — both `src/lib/<feature>/` (the
|
|
183
|
+
* default convention) and nested layouts like `src/lib/<group>/<feature>/`
|
|
184
|
+
* (used by demos that mirror multiple variants side by side) are valid.
|
|
185
|
+
* Typo'd feature names produce orphan folders that are easy to spot under
|
|
186
|
+
* `migrations/`.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} cwd
|
|
189
|
+
* @param {string} arg `<feature>/<name>` form
|
|
190
|
+
* @returns {string} the path of the created file
|
|
191
|
+
*/
|
|
192
|
+
export function createMigration(cwd, arg) {
|
|
193
|
+
if (!arg || !arg.includes('/')) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
'Usage: norns migrate create <feature>/<name>\nExample: norns migrate create notes/add_pinned_column'
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const [feature, ...rest] = arg.split('/');
|
|
199
|
+
const slug = rest.join('/').replace(/[^a-zA-Z0-9_]+/g, '_');
|
|
200
|
+
const migDir = join(migrationsRoot(cwd), feature);
|
|
201
|
+
mkdirSync(migDir, { recursive: true });
|
|
202
|
+
const ts = new Date()
|
|
203
|
+
.toISOString()
|
|
204
|
+
.replace(/[-:T]/g, '')
|
|
205
|
+
.replace(/\..+$/, '')
|
|
206
|
+
.slice(0, 14);
|
|
207
|
+
const file = join(migDir, `${ts}_${slug}.sql`);
|
|
208
|
+
writeFileSync(file, `-- ${feature}: ${slug}\n-- Created: ${new Date().toISOString()}\n\n`, {
|
|
209
|
+
flag: 'wx'
|
|
210
|
+
});
|
|
211
|
+
return file;
|
|
212
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { sequence } from '@sveltejs/kit/hooks';
|
|
2
|
+
import { Container } from './container.js';
|
|
3
|
+
import { contextHandle } from './handle/context.js';
|
|
4
|
+
import { errorHandle } from './handle/error.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a fresh root container with no features registered. Useful for tests
|
|
8
|
+
* that want full control over what's bound.
|
|
9
|
+
*
|
|
10
|
+
* @returns {Container}
|
|
11
|
+
*/
|
|
12
|
+
export function createApp() {
|
|
13
|
+
return new Container();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @typedef {(app: Container) => void | Promise<void>} ModuleRegister */
|
|
17
|
+
/** @typedef {{ default?: ModuleRegister } | ModuleRegister} FeatureModule */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Boot a Norns app: builds the root container, runs every feature's
|
|
21
|
+
* `module.c` registration, and returns the SvelteKit hooks ready to wire into
|
|
22
|
+
* `src/hooks.server.c`.
|
|
23
|
+
*
|
|
24
|
+
* Typical use in a consumer app:
|
|
25
|
+
*
|
|
26
|
+
* import { boot } from '@human-synthesis/norns/server';
|
|
27
|
+
*
|
|
28
|
+
* const app = await boot({
|
|
29
|
+
* features: import.meta.glob('./lib/*\/server/module.c', { eager: true })
|
|
30
|
+
* });
|
|
31
|
+
* export const { handle, handleError, container } = app;
|
|
32
|
+
*
|
|
33
|
+
* Each `module.c` must default-export a function `(app) -> ...` that calls
|
|
34
|
+
* `app.bind(...)` / `app.single(...)` / `app.migrations(...)`.
|
|
35
|
+
*
|
|
36
|
+
* @param {{
|
|
37
|
+
* features?: Record<string, FeatureModule>,
|
|
38
|
+
* extraHandle?: import('@sveltejs/kit').Handle | import('@sveltejs/kit').Handle[],
|
|
39
|
+
* handleError?: import('@sveltejs/kit').HandleServerError
|
|
40
|
+
* }} [opts]
|
|
41
|
+
* @returns {Promise<{
|
|
42
|
+
* container: Container,
|
|
43
|
+
* handle: import('@sveltejs/kit').Handle,
|
|
44
|
+
* handleError: import('@sveltejs/kit').HandleServerError
|
|
45
|
+
* }>}
|
|
46
|
+
*/
|
|
47
|
+
export async function boot(opts = {}) {
|
|
48
|
+
const container = createApp();
|
|
49
|
+
|
|
50
|
+
if (opts.features) {
|
|
51
|
+
for (const [path, mod] of Object.entries(opts.features)) {
|
|
52
|
+
const register = /** @type {ModuleRegister | undefined} */ (
|
|
53
|
+
typeof mod === 'function' ? mod : mod?.default
|
|
54
|
+
);
|
|
55
|
+
if (typeof register !== 'function') {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Norns: ${path} must default-export a function (app) -> ... — got ${typeof register}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
await register(container);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const extras = opts.extraHandle
|
|
65
|
+
? Array.isArray(opts.extraHandle)
|
|
66
|
+
? opts.extraHandle
|
|
67
|
+
: [opts.extraHandle]
|
|
68
|
+
: [];
|
|
69
|
+
|
|
70
|
+
const handle = sequence(contextHandle(container), ...extras);
|
|
71
|
+
const handleError = opts.handleError ?? errorHandle();
|
|
72
|
+
|
|
73
|
+
return { container, handle, handleError };
|
|
74
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Norns DI container.
|
|
3
|
+
*
|
|
4
|
+
* Tokens are namespaced strings (e.g. `notes.repo`, `db`). Bindings register a
|
|
5
|
+
* factory that produces an instance; `single` bindings memoize at the level
|
|
6
|
+
* where they are declared, `bind` bindings re-run the factory on every resolve.
|
|
7
|
+
*
|
|
8
|
+
* A child scope (created via `scope()`) inherits its parent's bindings but
|
|
9
|
+
* tracks its own overrides and request-scoped singletons. Request scopes are
|
|
10
|
+
* the natural fit for things like `db` (a transactional handle for one
|
|
11
|
+
* request) or `user` (the authenticated principal).
|
|
12
|
+
*
|
|
13
|
+
* Overrides take precedence over bindings at every level — used in tests to
|
|
14
|
+
* swap a real service for a fake without rebinding the production module.
|
|
15
|
+
*
|
|
16
|
+
* Migration directories are tracked at the root container only; child scopes
|
|
17
|
+
* inherit visibility through the root walk.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @typedef {(c: Container) => any} Factory */
|
|
21
|
+
/** @typedef {{ factory: Factory, lifetime: 'transient' | 'singleton' }} Binding */
|
|
22
|
+
|
|
23
|
+
export class Container {
|
|
24
|
+
/** @param {Container | null} [parent] */
|
|
25
|
+
constructor(parent = null) {
|
|
26
|
+
/** @type {Container | null} */
|
|
27
|
+
this.parent = parent;
|
|
28
|
+
/** @type {Map<string, Binding>} */
|
|
29
|
+
this.bindings = new Map();
|
|
30
|
+
/** @type {Map<string, any>} */
|
|
31
|
+
this.singletons = new Map();
|
|
32
|
+
/** @type {Map<string, Factory>} */
|
|
33
|
+
this.overrides = new Map();
|
|
34
|
+
/** @type {string[]} */
|
|
35
|
+
this.migrationDirs = [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bind a transient factory — called on every `resolve(token)`.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} token
|
|
42
|
+
* @param {Factory} factory
|
|
43
|
+
* @returns {this}
|
|
44
|
+
*/
|
|
45
|
+
bind(token, factory) {
|
|
46
|
+
this.bindings.set(token, { factory, lifetime: 'transient' });
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Bind a singleton factory — called once per container scope.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} token
|
|
54
|
+
* @param {Factory} factory
|
|
55
|
+
* @returns {this}
|
|
56
|
+
*/
|
|
57
|
+
single(token, factory) {
|
|
58
|
+
this.bindings.set(token, { factory, lifetime: 'singleton' });
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Override a token at this scope. Wins over any binding in this or any
|
|
64
|
+
* parent scope. Useful in tests.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} token
|
|
67
|
+
* @param {Factory} factory
|
|
68
|
+
* @returns {this}
|
|
69
|
+
*/
|
|
70
|
+
override(token, factory) {
|
|
71
|
+
this.overrides.set(token, factory);
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Register a migration directory. Tracked at the root container.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} dir absolute path to a `migrations/` directory containing
|
|
79
|
+
* `*.sql` files
|
|
80
|
+
* @returns {this}
|
|
81
|
+
*/
|
|
82
|
+
migrations(dir) {
|
|
83
|
+
let root = /** @type {Container} */ (this);
|
|
84
|
+
while (root.parent) root = root.parent;
|
|
85
|
+
root.migrationDirs.push(dir);
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get all registered migration directories. Walks to root.
|
|
91
|
+
*
|
|
92
|
+
* @returns {string[]}
|
|
93
|
+
*/
|
|
94
|
+
getMigrationDirs() {
|
|
95
|
+
let root = /** @type {Container} */ (this);
|
|
96
|
+
while (root.parent) root = root.parent;
|
|
97
|
+
return [...root.migrationDirs];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve a token to a value. Walks the scope chain.
|
|
102
|
+
*
|
|
103
|
+
* Resolution order:
|
|
104
|
+
* 1. nearest override (any scope)
|
|
105
|
+
* 2. nearest binding (any scope) — singletons cache at that scope
|
|
106
|
+
* 3. throw
|
|
107
|
+
*
|
|
108
|
+
* The factory is called with the leaf scope (the one `resolve` was called
|
|
109
|
+
* on), so factories can resolve other tokens at the same level.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} token
|
|
112
|
+
* @returns {any}
|
|
113
|
+
*/
|
|
114
|
+
resolve(token) {
|
|
115
|
+
let node = /** @type {Container | null} */ (this);
|
|
116
|
+
while (node) {
|
|
117
|
+
if (node.overrides.has(token)) {
|
|
118
|
+
const factory = /** @type {Factory} */ (node.overrides.get(token));
|
|
119
|
+
return factory(this);
|
|
120
|
+
}
|
|
121
|
+
node = node.parent;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
node = this;
|
|
125
|
+
while (node) {
|
|
126
|
+
const binding = node.bindings.get(token);
|
|
127
|
+
if (binding) {
|
|
128
|
+
if (binding.lifetime === 'singleton') {
|
|
129
|
+
if (!node.singletons.has(token)) {
|
|
130
|
+
node.singletons.set(token, binding.factory(this));
|
|
131
|
+
}
|
|
132
|
+
return node.singletons.get(token);
|
|
133
|
+
}
|
|
134
|
+
return binding.factory(this);
|
|
135
|
+
}
|
|
136
|
+
node = node.parent;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
throw new Error(`Container: no binding for token "${String(token)}"`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether a binding or override is reachable for the given token.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} token
|
|
146
|
+
* @returns {boolean}
|
|
147
|
+
*/
|
|
148
|
+
has(token) {
|
|
149
|
+
let node = /** @type {Container | null} */ (this);
|
|
150
|
+
while (node) {
|
|
151
|
+
if (node.overrides.has(token) || node.bindings.has(token)) return true;
|
|
152
|
+
node = node.parent;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Create a child scope. The child inherits bindings via the parent walk;
|
|
159
|
+
* its own overrides and singletons are isolated.
|
|
160
|
+
*
|
|
161
|
+
* @returns {Container}
|
|
162
|
+
*/
|
|
163
|
+
scope() {
|
|
164
|
+
return new Container(this);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Create a fresh root container.
|
|
170
|
+
*
|
|
171
|
+
* @returns {Container}
|
|
172
|
+
*/
|
|
173
|
+
export function createContainer() {
|
|
174
|
+
return new Container();
|
|
175
|
+
}
|
package/src/server/db.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle driver factories + a portable transaction helper.
|
|
3
|
+
*
|
|
4
|
+
* Drivers are imported lazily so consumers only need to install the ones they
|
|
5
|
+
* actually use. Drizzle and its driver packages are user-installed (not
|
|
6
|
+
* bundled in norns) — the framework just provides the assembly recipe.
|
|
7
|
+
*
|
|
8
|
+
* The dynamic `import()` paths are routed through a variable + `@vite-ignore`
|
|
9
|
+
* so Rollup doesn't try to resolve them at build time when the consumer
|
|
10
|
+
* hasn't installed the corresponding driver.
|
|
11
|
+
*
|
|
12
|
+
* Bind the result as `db` in your feature's `module.c`:
|
|
13
|
+
*
|
|
14
|
+
* import { betterSqlite } from '@human-synthesis/norns/server';
|
|
15
|
+
* module.exports = (app) ->
|
|
16
|
+
* dbInstance = await betterSqlite('data/notes.db', pragma: ['journal_mode = WAL'])
|
|
17
|
+
* app.single 'db', -> dbInstance
|
|
18
|
+
*
|
|
19
|
+
* For Cloudflare Workers, switch to `d1(env.DB)`. Same module.c shape.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Pass-through dynamic import that hides the path from Rollup's static
|
|
24
|
+
* analyzer. Without this, building an app that doesn't have (say)
|
|
25
|
+
* `drizzle-orm/d1` installed fails even if the app never calls `d1()`.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} mod
|
|
28
|
+
* @returns {Promise<any>}
|
|
29
|
+
*/
|
|
30
|
+
function importDynamic(mod) {
|
|
31
|
+
return import(/* @vite-ignore */ mod);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {Object} BetterSqliteOptions
|
|
36
|
+
* @property {Object} [connection] passed to `new Database(path, opts)`
|
|
37
|
+
* @property {string[]} [pragma] PRAGMA statements to run after open
|
|
38
|
+
* @property {Object} [drizzle] passed to `drizzle(sqlite, opts)`
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {Object} D1Options
|
|
43
|
+
* @property {Object} [drizzle] passed to `drizzle(binding, opts)`
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {Object} LibsqlOptions
|
|
48
|
+
* @property {Object} [client] passed to `createClient({ url, ...client })`
|
|
49
|
+
* @property {Object} [drizzle] passed to `drizzle(client, opts)`
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {Object} PostgresOptions
|
|
54
|
+
* @property {Object} [pool] passed to `new Pool({ connectionString: url, ...pool })`
|
|
55
|
+
* @property {Object} [drizzle] passed to `drizzle(pool, opts)`
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Open a Drizzle instance backed by `better-sqlite3`.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} path SQLite file path (e.g. `data/notes.db`)
|
|
62
|
+
* @param {BetterSqliteOptions} [opts]
|
|
63
|
+
* @returns {Promise<any>}
|
|
64
|
+
*/
|
|
65
|
+
export async function betterSqlite(path, opts = {}) {
|
|
66
|
+
const [{ default: Database }, { drizzle }] = await Promise.all([
|
|
67
|
+
importDynamic('better-sqlite3'),
|
|
68
|
+
importDynamic('drizzle-orm/better-sqlite3')
|
|
69
|
+
]);
|
|
70
|
+
const sqlite = new Database(path, opts.connection);
|
|
71
|
+
if (opts.pragma) {
|
|
72
|
+
for (const p of opts.pragma) sqlite.pragma(p);
|
|
73
|
+
}
|
|
74
|
+
return drizzle(sqlite, opts.drizzle);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Open a Drizzle instance backed by Cloudflare D1.
|
|
79
|
+
*
|
|
80
|
+
* @param {any} binding D1 binding from `event.platform.env`
|
|
81
|
+
* @param {D1Options} [opts]
|
|
82
|
+
* @returns {Promise<any>}
|
|
83
|
+
*/
|
|
84
|
+
export async function d1(binding, opts = {}) {
|
|
85
|
+
const { drizzle } = await importDynamic('drizzle-orm/d1');
|
|
86
|
+
return drizzle(binding, opts.drizzle);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Open a Drizzle instance backed by libSQL (Turso, sqld).
|
|
91
|
+
*
|
|
92
|
+
* @param {string} url
|
|
93
|
+
* @param {LibsqlOptions} [opts]
|
|
94
|
+
* @returns {Promise<any>}
|
|
95
|
+
*/
|
|
96
|
+
export async function libsql(url, opts = {}) {
|
|
97
|
+
const [{ createClient }, { drizzle }] = await Promise.all([
|
|
98
|
+
importDynamic('@libsql/client'),
|
|
99
|
+
importDynamic('drizzle-orm/libsql')
|
|
100
|
+
]);
|
|
101
|
+
const client = createClient({ url, ...(opts.client ?? {}) });
|
|
102
|
+
return drizzle(client, opts.drizzle);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Open a Drizzle instance backed by node-postgres.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} url
|
|
109
|
+
* @param {PostgresOptions} [opts]
|
|
110
|
+
* @returns {Promise<any>}
|
|
111
|
+
*/
|
|
112
|
+
export async function postgres(url, opts = {}) {
|
|
113
|
+
const [{ default: pgModule }, { drizzle }] = await Promise.all([
|
|
114
|
+
importDynamic('pg'),
|
|
115
|
+
importDynamic('drizzle-orm/node-postgres')
|
|
116
|
+
]);
|
|
117
|
+
const pool = new pgModule.Pool({ connectionString: url, ...(opts.pool ?? {}) });
|
|
118
|
+
return drizzle(pool, opts.drizzle);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Run `fn` inside a Drizzle transaction. Uniform across drivers.
|
|
123
|
+
*
|
|
124
|
+
* @template T
|
|
125
|
+
* @param {any} db Drizzle instance
|
|
126
|
+
* @param {(tx: any) => T | Promise<T>} fn
|
|
127
|
+
* @returns {Promise<T>}
|
|
128
|
+
*/
|
|
129
|
+
export function withTransaction(db, fn) {
|
|
130
|
+
return db.transaction(fn);
|
|
131
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { withScope } from '../scope.js';
|
|
2
|
+
|
|
3
|
+
/** @typedef {import('../container.js').Container} Container */
|
|
4
|
+
/** @typedef {import('@sveltejs/kit').Handle} Handle */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-request middleware: creates a child scope of the root container and
|
|
8
|
+
* attaches it to `event.locals.container`. Anything resolved through
|
|
9
|
+
* `event.locals.container` for the lifetime of the request hits this scope —
|
|
10
|
+
* overrides and request-scoped singletons (like `db`) live here.
|
|
11
|
+
*
|
|
12
|
+
* Also runs the rest of the pipeline inside `withScope()` so AsyncLocalStorage
|
|
13
|
+
* makes the scope available to callees that don't get `event` directly.
|
|
14
|
+
*
|
|
15
|
+
* @param {Container} app the root container produced by `createApp()`/`boot()`
|
|
16
|
+
* @returns {Handle}
|
|
17
|
+
*/
|
|
18
|
+
export function contextHandle(app) {
|
|
19
|
+
return async ({ event, resolve }) => {
|
|
20
|
+
const scope = app.scope();
|
|
21
|
+
event.locals.container = scope;
|
|
22
|
+
return withScope({ container: scope, event }, () => resolve(event));
|
|
23
|
+
};
|
|
24
|
+
}
|