@uniweb/build 0.37.1 → 0.39.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/package.json +8 -10
- package/src/prerender.js +65 -22
- package/src/runtime-schema.js +17 -2
- package/src/schema.js +73 -0
- package/src/site/build-site-data.js +5 -0
- package/src/site/content-collector.js +59 -7
- package/src/site/data-fetcher.js +168 -28
- package/src/site/plugin.js +17 -5
- package/src/site/queries-config.js +2 -3
- package/src/site/query-processor.js +24 -7
- package/src/site/records-config.js +10 -5
- package/src/uwx/folder.js +27 -14
- package/src/uwx/foundation-schema.js +10 -3
- package/src/uwx/records-project.js +19 -13
- package/src/uwx/records.js +24 -10
- package/src/uwx/registry-package.js +10 -1
- package/src/uwx/site-project.js +6 -1
- package/src/uwx/site.js +8 -4
- package/src/uwx/sync-package.js +1 -0
- package/src/dev-backend.js +0 -247
package/src/uwx/records.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// referenced BY NAME, on the entity-content SYNC lane.
|
|
3
3
|
//
|
|
4
4
|
// Each record becomes a section-keyed `$`-document (docs/reference/entity-content.md):
|
|
5
|
-
// `$id` (the
|
|
5
|
+
// `$id` (the producer-local handle), `$model` (the Model by name), and
|
|
6
6
|
// each SINGLE section keyed by its name — the brief plus any sibling singles, not
|
|
7
7
|
// the brief alone. The backend MINTS `$uuid` on first sync and
|
|
8
8
|
// returns it in the finalized response; the verb back-fills it into the source
|
|
@@ -91,8 +91,9 @@ function stripSigils(value) {
|
|
|
91
91
|
// ⛔ A `@uniweb/folder` REF LEAF ENCODES ONE REFERENCE TWO WAYS, and hashing the
|
|
92
92
|
// encoding rather than the reference made the folder's hash unreproducible.
|
|
93
93
|
//
|
|
94
|
-
// `refLeaf` (uwx/folder.js) emits `$ref:
|
|
95
|
-
// is brand-new and `entry: { model, entity:
|
|
94
|
+
// `refLeaf` (uwx/folder.js) emits `$ref: <the record's $id>` — the pool position
|
|
95
|
+
// `<dirs>/<slug>` — while the record is brand-new, and `entry: { model, entity:
|
|
96
|
+
// <uuid> }` once it has been minted.
|
|
96
97
|
// Both denote the same record. A push hashes the folder BEFORE submitting, then
|
|
97
98
|
// back-fills the minted `$uuid` into every record's source file — so the very
|
|
98
99
|
// next emit builds the OTHER encoding, and the hash the push just banked can
|
|
@@ -103,14 +104,14 @@ function stripSigils(value) {
|
|
|
103
104
|
//
|
|
104
105
|
// ⭐ Neither encoding is content. What the folder SAYS is "this branch contains
|
|
105
106
|
// this record, here, in this order" — and that is already hashed: a leaf carries
|
|
106
|
-
// `
|
|
107
|
+
// `name` (the record's handle) inside a branch carrying the collection's.
|
|
107
108
|
// A `folders:` branch's entries are COLLECTION names, so every leaf under one
|
|
108
109
|
// comes from a single collection, where a slug is unique. Position plus segment
|
|
109
110
|
// therefore identify the record on their own; `$ref` adds a payload-local handle
|
|
110
111
|
// and `entry` adds identity, and both are exactly what `$uuid` is stripped for.
|
|
111
112
|
//
|
|
112
113
|
// ⚖️ The previous rule kept `$ref` "so a reference change is visible". It still
|
|
113
|
-
// is: point a leaf at a different record and its `
|
|
114
|
+
// is: point a leaf at a different record and its `name` moves with it.
|
|
114
115
|
const isFolderRefLeaf = value.kind === 'ref'
|
|
115
116
|
const out = {}
|
|
116
117
|
for (const [k, v] of Object.entries(value)) {
|
|
@@ -181,7 +182,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
181
182
|
* already carries `$uuid` (back-filled from a prior sync) round-trips it.
|
|
182
183
|
*
|
|
183
184
|
* @param {object} params
|
|
184
|
-
* @param {string} params.queryName - the site.yml
|
|
185
|
+
* @param {string} params.queryName - the query's name in site.yml
|
|
185
186
|
* @param {object[]} params.records - [{ slug, ...fields }]
|
|
186
187
|
* @param {object} params.declaration - the `@uniweb/data-schema` declaration
|
|
187
188
|
* (from toDataSchemaDeclaration): `{ name, brief, sections }`
|
|
@@ -272,9 +273,22 @@ export function recordsToEntities({
|
|
|
272
273
|
warnings.push(`${queryName}: a record without a slug was skipped`)
|
|
273
274
|
continue
|
|
274
275
|
}
|
|
275
|
-
// `$id` is the payload-local handle
|
|
276
|
-
//
|
|
277
|
-
//
|
|
276
|
+
// ⛔ `$id` IS NOT THE SLUG. It is the payload-local, PATH-QUALIFIED handle, so
|
|
277
|
+
// the @uniweb/folder entity can point a leaf at it via `$ref`. An explicit
|
|
278
|
+
// frontmatter `$id` wins.
|
|
279
|
+
//
|
|
280
|
+
// ⚠️ The authoritative value is the record's POOL POSITION — `<dirs>/<slug>` —
|
|
281
|
+
// and it is set upstream, at the pool walk; see the ⭐ comment there, which is
|
|
282
|
+
// where the reasoning lives. `<query>/<slug>` below is only the fallback for a
|
|
283
|
+
// record that did not arrive through the pool, and it is explicitly NOT the
|
|
284
|
+
// shape identity is meant to take: two queries over one Model would mint two
|
|
285
|
+
// identities for one file.
|
|
286
|
+
//
|
|
287
|
+
// The qualification is a CONSTRAINT, not a style: the sync response is keyed per
|
|
288
|
+
// (`$model`, `$id`), so a bare slug would collide whenever two queries over the
|
|
289
|
+
// same Model reuse one (see the duplicate check below). ⇒ Do not describe this
|
|
290
|
+
// value as "the slug" — the folder leaf's `name` is the bare segment, and
|
|
291
|
+
// conflating the two has already misdirected a naming decision.
|
|
278
292
|
const id = record.$id || `${queryName}/${slug}`
|
|
279
293
|
const uuid = record.$uuid || null
|
|
280
294
|
const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
|
|
@@ -631,7 +645,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
631
645
|
// statically (the "data ball") instead, so the caller can route them there.
|
|
632
646
|
const schemaless = []
|
|
633
647
|
// The sync response is keyed per ($model, $id), so the pair must be unique
|
|
634
|
-
// within one submission (two
|
|
648
|
+
// within one submission (two queries over the same Model could otherwise
|
|
635
649
|
// reuse a slug).
|
|
636
650
|
const seen = new Set()
|
|
637
651
|
for (const { name, decl } of mapped) {
|
|
@@ -159,6 +159,11 @@ function buildInfo(self, org, digest, runtime) {
|
|
|
159
159
|
// be satisfied. Same lift as `digest`: stated by the producer, opaque to the
|
|
160
160
|
// backend, acted on by whoever resolves a whole site.
|
|
161
161
|
if (runtime) info.runtime = runtime
|
|
162
|
+
// The host services this foundation is BUILT AGAINST, from package.json's
|
|
163
|
+
// `uniweb.supports`. `Array.isArray` and not truthiness: `[]` is an explicit
|
|
164
|
+
// "none" and must survive as one, while an ABSENT key means UNKNOWN — the
|
|
165
|
+
// same three-state rule `runtime` states above, for the same reason.
|
|
166
|
+
if (Array.isArray(self.supports)) info.supports = self.supports
|
|
162
167
|
return info
|
|
163
168
|
}
|
|
164
169
|
|
|
@@ -166,7 +171,11 @@ function buildInfo(self, org, digest, runtime) {
|
|
|
166
171
|
// as one opaque object the backend never reads into (custodian).
|
|
167
172
|
function buildSchemaBlob(schema) {
|
|
168
173
|
const { dataSchemas: _ds, ...rest } = schema
|
|
169
|
-
|
|
174
|
+
// Every key hoisted into `info` is stripped here, so the wire carries each
|
|
175
|
+
// fact ONCE. Two copies of one fact is a drift liability, and the copy inside
|
|
176
|
+
// an opaque blob is the one nobody would think to update.
|
|
177
|
+
const { name: _n, version: _v, description: _d, role: _r, supports: _s, ...selfConfig } =
|
|
178
|
+
rest._self || {}
|
|
170
179
|
return { ...rest, _self: selfConfig }
|
|
171
180
|
}
|
|
172
181
|
|
package/src/uwx/site-project.js
CHANGED
|
@@ -513,7 +513,12 @@ function projectPages(pages, pagesDir, sourceLocale, report, prune, ctx) {
|
|
|
513
513
|
// is already a plain string.
|
|
514
514
|
export function pageDirName(record, sourceLocale) {
|
|
515
515
|
const slug = unwrapLocalized(record.slug, sourceLocale)
|
|
516
|
-
|
|
516
|
+
if (!record.is_dynamic) return slug
|
|
517
|
+
// The multi-segment folder rides the wire as slug `...path` with
|
|
518
|
+
// `param_name: slug` (the handle it delivers by); it comes back as the one
|
|
519
|
+
// fixed spelling, never as `[slug]`.
|
|
520
|
+
if (slug === '...path') return '[...path]'
|
|
521
|
+
return `[${record.param_name || slug}]`
|
|
517
522
|
}
|
|
518
523
|
|
|
519
524
|
// Pass 1 — write + relocate every page dir, its page.yml/folder.yml, and its
|
package/src/uwx/site.js
CHANGED
|
@@ -334,6 +334,12 @@ async function orderedSubfolders(dirPath, inheritedMode, parentConfig) {
|
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
const DYNAMIC_RE = /^\[(.+)\]$/
|
|
337
|
+
// The multi-segment route folder, one fixed spelling (`content-collector.js`).
|
|
338
|
+
// On the wire its page `slug` is the marker itself (`...path`) and its
|
|
339
|
+
// `param_name` is `slug`: the record is delivered by its handle, the last
|
|
340
|
+
// segment, exactly as under `[slug]`. ⚠️ What a consumer's projector emits as
|
|
341
|
+
// the page ROUTE for it is that consumer's; framework expects `/…/:path*`.
|
|
342
|
+
const CATCH_ALL_MARKER = '...path'
|
|
337
343
|
|
|
338
344
|
// ===========================================================================
|
|
339
345
|
// NESTED ($-document) lane — Phase 0 de-flatten (bidirectional-sync §8).
|
|
@@ -545,7 +551,7 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
|
|
|
545
551
|
slug,
|
|
546
552
|
mode,
|
|
547
553
|
isDynamic: !!dyn,
|
|
548
|
-
paramName: dyn ? dyn[1] : undefined,
|
|
554
|
+
paramName: dyn ? (dyn[1] === CATCH_ALL_MARKER ? 'slug' : dyn[1]) : undefined,
|
|
549
555
|
isRoot,
|
|
550
556
|
siteIndex,
|
|
551
557
|
sourceLocale,
|
|
@@ -684,7 +690,7 @@ export function isSiteRelativeExtensionUrl(decl) {
|
|
|
684
690
|
* declaration could occupy, nothing is ever recorded for one, and every push re-sent
|
|
685
691
|
* this whole section uuid-less. The backend refuses that (an all-blank section over
|
|
686
692
|
* stored items would delete every stored row), which is why `push` worked once and
|
|
687
|
-
* every push after it was refused. Measured 2026-08-29; collab framework
|
|
693
|
+
* every push after it was refused. Measured 2026-08-29; collab framework↔backend.
|
|
688
694
|
*
|
|
689
695
|
* ⭐ `name` is the right key and not merely the available one — the backend enforces
|
|
690
696
|
* `unique_field(name, scope: section)` on this section, and it is the join key its
|
|
@@ -724,8 +730,6 @@ export function isSiteRelativeExtensionUrl(decl) {
|
|
|
724
730
|
* `site.yml collections.<name>.label`; no such field has ever existed, and they have
|
|
725
731
|
* corrected it.
|
|
726
732
|
*
|
|
727
|
-
* ⇒ Full record, including what is established vs merely claimed:
|
|
728
|
-
* `kb/framework/build/collections-decl-open-questions.md`.
|
|
729
733
|
*
|
|
730
734
|
* @param {object} declarations resolved collection declarations, keyed by name
|
|
731
735
|
* @param {Object<string,string>} [uuids] `name` → backend `$uuid`, from a push
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -276,6 +276,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
276
276
|
// are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
|
|
277
277
|
const folder = buildFolderEntity({
|
|
278
278
|
recordEntities: col.entities,
|
|
279
|
+
...(sourceLocale ? { sourceLocale } : {}),
|
|
279
280
|
// ⭐ AUTHORED, from `records.yml`. It used to be derived — one branch per
|
|
280
281
|
// collection — which made the folder a shadow of a directory layout rather
|
|
281
282
|
// than something the author states.
|
package/src/dev-backend.js
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Dev backend for testing Uniweb sites with `supports: [where, limit, sort]`.
|
|
4
|
-
*
|
|
5
|
-
* Reads a directory of YAML recordsByQuery (each subfolder is a collection,
|
|
6
|
-
* each .yml file inside is a record) and exposes them via HTTP. Evaluates
|
|
7
|
-
* where-objects on the server side using @uniweb/core's matchWhere — the
|
|
8
|
-
* exact same evaluator the runtime uses as a fallback. This lets you
|
|
9
|
-
* develop a site against a "real" backend without standing up a database.
|
|
10
|
-
*
|
|
11
|
-
* Wire format matches the framework default fetcher's pushdown conventions
|
|
12
|
-
* (see framework/runtime/src/default-fetcher.js):
|
|
13
|
-
*
|
|
14
|
-
* GET /api/{collection} — all records
|
|
15
|
-
* GET /api/{collection}?_where=<JSON> — filtered by where-object
|
|
16
|
-
* GET /api/{collection}?_limit=N — first N records
|
|
17
|
-
* GET /api/{collection}?_sort=field:dir — sorted
|
|
18
|
-
* POST /api/{collection} body: { where, ... } — operators in body
|
|
19
|
-
* GET /api/{collection}/{slug} — single record
|
|
20
|
-
*
|
|
21
|
-
* Usage:
|
|
22
|
-
* node scripts/framework/dev-backend.js --recordsByQuery <path> [--port N]
|
|
23
|
-
*
|
|
24
|
-
* Example (academic-metrics):
|
|
25
|
-
* node scripts/framework/dev-backend.js \
|
|
26
|
-
* --recordsByQuery framework/templates/academic-metrics/site/recordsByQuery \
|
|
27
|
-
* --port 8080
|
|
28
|
-
*
|
|
29
|
-
* Then in the site's site.yml:
|
|
30
|
-
* fetcher:
|
|
31
|
-
* baseUrl: http://localhost:8080
|
|
32
|
-
* supports: [where, limit, sort]
|
|
33
|
-
*
|
|
34
|
-
* And rewrite collection refs to URLs, e.g.:
|
|
35
|
-
* fetch: { url: /api/members, schema: members }
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
import { createServer } from 'node:http'
|
|
39
|
-
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
40
|
-
import { existsSync } from 'node:fs'
|
|
41
|
-
import { resolve, join, basename, extname } from 'node:path'
|
|
42
|
-
import { parseArgs } from 'node:util'
|
|
43
|
-
import yaml from 'js-yaml'
|
|
44
|
-
import { matchWhere } from '@uniweb/core'
|
|
45
|
-
|
|
46
|
-
const { values } = parseArgs({
|
|
47
|
-
options: {
|
|
48
|
-
entities: { type: 'string', short: 'e' },
|
|
49
|
-
port: { type: 'string', short: 'p', default: '8080' },
|
|
50
|
-
},
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
if (!values.entities) {
|
|
54
|
-
console.error('Usage: dev-backend.js --entities <path> [--port N]')
|
|
55
|
-
process.exit(1)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const ENTITIES_ROOT = resolve(values.entities)
|
|
59
|
-
const PORT = Number(values.port)
|
|
60
|
-
|
|
61
|
-
if (!existsSync(ENTITIES_ROOT)) {
|
|
62
|
-
console.error(`Entities directory not found: ${ENTITIES_ROOT}`)
|
|
63
|
-
process.exit(1)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ─── Load recordsByQuery from disk ─────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
async function loadRecords(dir) {
|
|
69
|
-
const files = await readdir(dir)
|
|
70
|
-
const items = []
|
|
71
|
-
for (const file of files) {
|
|
72
|
-
const ext = extname(file).toLowerCase()
|
|
73
|
-
if (!['.yml', '.yaml', '.json'].includes(ext)) continue
|
|
74
|
-
const filepath = join(dir, file)
|
|
75
|
-
const content = await readFile(filepath, 'utf8')
|
|
76
|
-
let data
|
|
77
|
-
try {
|
|
78
|
-
data = ext === '.json' ? JSON.parse(content) : yaml.load(content)
|
|
79
|
-
} catch (err) {
|
|
80
|
-
console.warn(`[dev-backend] Failed to parse ${filepath}: ${err.message}`)
|
|
81
|
-
continue
|
|
82
|
-
}
|
|
83
|
-
if (data == null) continue
|
|
84
|
-
const slug = basename(file, ext)
|
|
85
|
-
if (Array.isArray(data)) {
|
|
86
|
-
// Array-form file: each element is a record.
|
|
87
|
-
for (const record of data) {
|
|
88
|
-
if (record && typeof record === 'object') items.push(record)
|
|
89
|
-
}
|
|
90
|
-
} else if (typeof data === 'object') {
|
|
91
|
-
items.push({ slug, ...data })
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return items
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function loadAllRecords() {
|
|
98
|
-
const entries = await readdir(ENTITIES_ROOT)
|
|
99
|
-
const recordsByQuery = {}
|
|
100
|
-
for (const name of entries) {
|
|
101
|
-
const fullPath = join(ENTITIES_ROOT, name)
|
|
102
|
-
const s = await stat(fullPath)
|
|
103
|
-
if (!s.isDirectory()) continue
|
|
104
|
-
recordsByQuery[name] = await loadRecords(fullPath)
|
|
105
|
-
console.log(`[dev-backend] Loaded ${recordsByQuery[name].length} items from "${name}"`)
|
|
106
|
-
}
|
|
107
|
-
return recordsByQuery
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ─── Operator handling (mirrors default-fetcher pushdown wire format) ───────
|
|
111
|
-
|
|
112
|
-
function applyOperators(items, operators) {
|
|
113
|
-
let result = items
|
|
114
|
-
if (operators.where) {
|
|
115
|
-
result = matchWhere(operators.where, result)
|
|
116
|
-
}
|
|
117
|
-
if (operators.sort) {
|
|
118
|
-
result = applySort(result, operators.sort)
|
|
119
|
-
}
|
|
120
|
-
if (typeof operators.limit === 'number' && operators.limit > 0) {
|
|
121
|
-
result = result.slice(0, operators.limit)
|
|
122
|
-
}
|
|
123
|
-
return result
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function applySort(items, sortExpr) {
|
|
127
|
-
const sorts = String(sortExpr).split(',').map((s) => {
|
|
128
|
-
const [field, dir = 'asc'] = s.trim().split(/\s+/)
|
|
129
|
-
return { field, desc: dir.toLowerCase() === 'desc' }
|
|
130
|
-
})
|
|
131
|
-
return [...items].sort((a, b) => {
|
|
132
|
-
for (const { field, desc } of sorts) {
|
|
133
|
-
const av = a?.[field] ?? ''
|
|
134
|
-
const bv = b?.[field] ?? ''
|
|
135
|
-
if (av < bv) return desc ? 1 : -1
|
|
136
|
-
if (av > bv) return desc ? -1 : 1
|
|
137
|
-
}
|
|
138
|
-
return 0
|
|
139
|
-
})
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function parseOperatorsFromQuery(searchParams) {
|
|
143
|
-
const out = {}
|
|
144
|
-
if (searchParams.has('_where')) {
|
|
145
|
-
try {
|
|
146
|
-
out.where = JSON.parse(searchParams.get('_where'))
|
|
147
|
-
} catch (err) {
|
|
148
|
-
throw new Error(`Invalid _where JSON: ${err.message}`)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
if (searchParams.has('_limit')) {
|
|
152
|
-
out.limit = Number(searchParams.get('_limit'))
|
|
153
|
-
}
|
|
154
|
-
if (searchParams.has('_sort')) {
|
|
155
|
-
out.sort = searchParams.get('_sort')
|
|
156
|
-
}
|
|
157
|
-
return out
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
async function readJsonBody(req) {
|
|
161
|
-
return new Promise((resolve, reject) => {
|
|
162
|
-
let body = ''
|
|
163
|
-
req.on('data', (chunk) => { body += chunk })
|
|
164
|
-
req.on('end', () => {
|
|
165
|
-
if (!body) return resolve({})
|
|
166
|
-
try { resolve(JSON.parse(body)) }
|
|
167
|
-
catch (err) { reject(new Error(`Invalid JSON body: ${err.message}`)) }
|
|
168
|
-
})
|
|
169
|
-
req.on('error', reject)
|
|
170
|
-
})
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// ─── HTTP server ────────────────────────────────────────────────────────────
|
|
174
|
-
|
|
175
|
-
function send(res, status, body) {
|
|
176
|
-
res.writeHead(status, {
|
|
177
|
-
'Content-Type': 'application/json',
|
|
178
|
-
'Access-Control-Allow-Origin': '*',
|
|
179
|
-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
180
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
181
|
-
})
|
|
182
|
-
res.end(typeof body === 'string' ? body : JSON.stringify(body))
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async function handleRequest(req, res, recordsByQuery) {
|
|
186
|
-
if (req.method === 'OPTIONS') return send(res, 204, '')
|
|
187
|
-
|
|
188
|
-
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
189
|
-
const match = url.pathname.match(/^\/api\/([^/]+)(?:\/([^/]+))?$/)
|
|
190
|
-
if (!match) return send(res, 404, { error: 'Not found' })
|
|
191
|
-
|
|
192
|
-
const [, queryName, slug] = match
|
|
193
|
-
const items = recordsByQuery[queryName]
|
|
194
|
-
if (!items) return send(res, 404, { error: `Unknown query: ${queryName}` })
|
|
195
|
-
|
|
196
|
-
// Single record by slug.
|
|
197
|
-
if (slug) {
|
|
198
|
-
const item = items.find((r) => r?.slug === slug)
|
|
199
|
-
if (!item) return send(res, 404, { error: `No record with slug "${slug}"` })
|
|
200
|
-
return send(res, 200, item)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Collection — apply operators from query string (GET) or body (POST).
|
|
204
|
-
let operators
|
|
205
|
-
try {
|
|
206
|
-
operators = req.method === 'POST'
|
|
207
|
-
? await readJsonBody(req)
|
|
208
|
-
: parseOperatorsFromQuery(url.searchParams)
|
|
209
|
-
} catch (err) {
|
|
210
|
-
return send(res, 400, { error: err.message })
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
let result
|
|
214
|
-
try {
|
|
215
|
-
result = applyOperators(items, operators)
|
|
216
|
-
} catch (err) {
|
|
217
|
-
return send(res, 400, { error: `Operator evaluation failed: ${err.message}` })
|
|
218
|
-
}
|
|
219
|
-
return send(res, 200, result)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// ─── Boot ───────────────────────────────────────────────────────────────────
|
|
223
|
-
|
|
224
|
-
const recordsByQuery = await loadAllRecords()
|
|
225
|
-
const knownQueries = Object.keys(recordsByQuery)
|
|
226
|
-
if (knownQueries.length === 0) {
|
|
227
|
-
console.warn('[dev-backend] No recordsByQuery found.')
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const server = createServer((req, res) => {
|
|
231
|
-
handleRequest(req, res, recordsByQuery).catch((err) => {
|
|
232
|
-
console.error('[dev-backend] Request handler threw:', err)
|
|
233
|
-
send(res, 500, { error: 'Internal server error' })
|
|
234
|
-
})
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
server.listen(PORT, () => {
|
|
238
|
-
console.log(`[dev-backend] Listening on http://localhost:${PORT}`)
|
|
239
|
-
console.log(`[dev-backend] Queries: ${knownQueries.join(', ') || '(none)'}`)
|
|
240
|
-
console.log('[dev-backend] Endpoints:')
|
|
241
|
-
for (const name of knownQueries) {
|
|
242
|
-
console.log(` GET /api/${name} — all records`)
|
|
243
|
-
console.log(` GET /api/${name}?_where=<JSON> — filtered`)
|
|
244
|
-
console.log(` GET /api/${name}/{slug} — single record`)
|
|
245
|
-
console.log(` POST /api/${name} body: { where } — operators in body`)
|
|
246
|
-
}
|
|
247
|
-
})
|