@nifrajs/content 2.13.0 → 3.1.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/README.md +25 -0
- package/package.json +1 -1
- package/src/index.ts +624 -0
package/README.md
CHANGED
|
@@ -15,6 +15,31 @@ bun add @nifrajs/content
|
|
|
15
15
|
- Reference: <https://nifra.dev/docs>
|
|
16
16
|
- AI-readable: <https://nifra.dev/llms.txt>
|
|
17
17
|
|
|
18
|
+
## Typed indexes and joins
|
|
19
|
+
|
|
20
|
+
Build an equality lookup and deterministic query index from a baked collection. `where` is field
|
|
21
|
+
equality, `range` supports typed string/number comparisons, sorting is stable (slug is the tie-breaker),
|
|
22
|
+
and pages use an opaque cursor rather than an offset:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
const posts = indexCollection(await bakeCollection(blog), {
|
|
26
|
+
by: "section",
|
|
27
|
+
sort: { field: "publishedAt", dir: "desc" },
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const page = posts.query({ where: { section: "news" }, limit: 20 })
|
|
31
|
+
const next = page.nextCursor === undefined
|
|
32
|
+
? undefined
|
|
33
|
+
: posts.query({ where: { section: "news" }, cursor: page.nextCursor, limit: 20 })
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`posts.baked` is JSON-safe and can be serialized into an edge bundle; `fromBakedIndex` rehydrates it
|
|
37
|
+
without filesystem access. `joinCollections(left, right, "id")` gives one-to-many rows by default.
|
|
38
|
+
Pass `{ cardinality: "one-to-one" }` to require unique right-side keys; duplicate right keys fail the
|
|
39
|
+
build loudly. The join key must exist on both frontmatter types with the same TypeScript value type.
|
|
40
|
+
These APIs index only validated local/baked content; credentialed CMS ingestion and tenant data remain
|
|
41
|
+
outside this public package.
|
|
42
|
+
|
|
18
43
|
MIT
|
|
19
44
|
|
|
20
45
|
## For AI agents
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -140,3 +140,627 @@ export function fromBaked<Frontmatter>(
|
|
|
140
140
|
get: (slug) => Promise.resolve(bySlug.get(slug) ?? null),
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
/** A string frontmatter field name accepted by the index APIs. */
|
|
145
|
+
export type ContentFieldKey<Frontmatter extends Record<string, unknown>> = Extract<
|
|
146
|
+
keyof Frontmatter,
|
|
147
|
+
string
|
|
148
|
+
>
|
|
149
|
+
|
|
150
|
+
/** A declared sort order. Ties are always broken by slug, then source order. */
|
|
151
|
+
export interface IndexSort<Frontmatter extends Record<string, unknown>> {
|
|
152
|
+
readonly field: ContentFieldKey<Frontmatter>
|
|
153
|
+
readonly dir: "asc" | "desc"
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Field-equality filters. Arbitrary predicates are intentionally not part of baked indexes. */
|
|
157
|
+
export type IndexWhere<Frontmatter extends Record<string, unknown>> = Partial<{
|
|
158
|
+
[K in ContentFieldKey<Frontmatter>]: Frontmatter[K]
|
|
159
|
+
}>
|
|
160
|
+
|
|
161
|
+
/** Range operators are available for string and number fields only. */
|
|
162
|
+
export type IndexRange<Value> = [Extract<Value, string | number>] extends [never]
|
|
163
|
+
? never
|
|
164
|
+
: {
|
|
165
|
+
readonly gt?: Extract<Value, string | number>
|
|
166
|
+
readonly gte?: Extract<Value, string | number>
|
|
167
|
+
readonly lt?: Extract<Value, string | number>
|
|
168
|
+
readonly lte?: Extract<Value, string | number>
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Typed range filters derived from the collection's frontmatter. */
|
|
172
|
+
export type IndexRanges<Frontmatter extends Record<string, unknown>> = Partial<{
|
|
173
|
+
[K in ContentFieldKey<Frontmatter>]: IndexRange<Frontmatter[K]>
|
|
174
|
+
}>
|
|
175
|
+
|
|
176
|
+
/** A bounded, cursor-based index query. The cursor is opaque and tied to its filter + sort. */
|
|
177
|
+
export interface IndexQueryOptions<Frontmatter extends Record<string, unknown>> {
|
|
178
|
+
readonly where?: IndexWhere<Frontmatter>
|
|
179
|
+
readonly range?: IndexRanges<Frontmatter>
|
|
180
|
+
readonly sort?: IndexSort<Frontmatter>
|
|
181
|
+
readonly cursor?: string
|
|
182
|
+
/** Defaults to 20; values above 100 are rejected to bound request-driven work. */
|
|
183
|
+
readonly limit?: number
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One page from an indexed collection. `nextCursor` is absent on the final page. */
|
|
187
|
+
export interface IndexPage<Frontmatter extends Record<string, unknown>> {
|
|
188
|
+
readonly items: ReadonlyArray<Entry<Frontmatter>>
|
|
189
|
+
readonly nextCursor?: string
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The JSON-safe lookup bucket stored in a baked index. */
|
|
193
|
+
export interface BakedIndexBucket {
|
|
194
|
+
readonly key: string
|
|
195
|
+
readonly entryIndexes: ReadonlyArray<number>
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** JSON-safe index data. It contains no functions, Map, Set, filesystem handle, or request data. */
|
|
199
|
+
export interface BakedCollectionIndex<
|
|
200
|
+
Frontmatter extends Record<string, unknown>,
|
|
201
|
+
By extends ContentFieldKey<Frontmatter>,
|
|
202
|
+
> {
|
|
203
|
+
readonly entries: ReadonlyArray<Entry<Frontmatter>>
|
|
204
|
+
readonly by: By
|
|
205
|
+
readonly sort: IndexSort<Frontmatter>
|
|
206
|
+
/** Entry indexes in the default deterministic order. */
|
|
207
|
+
readonly order: ReadonlyArray<number>
|
|
208
|
+
readonly buckets: ReadonlyArray<BakedIndexBucket>
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A build-time/runtime index over validated, baked entries. */
|
|
212
|
+
export interface IndexedCollection<
|
|
213
|
+
Frontmatter extends Record<string, unknown>,
|
|
214
|
+
By extends ContentFieldKey<Frontmatter>,
|
|
215
|
+
> {
|
|
216
|
+
readonly entries: ReadonlyArray<Entry<Frontmatter>>
|
|
217
|
+
readonly by: By
|
|
218
|
+
readonly baked: BakedCollectionIndex<Frontmatter, By>
|
|
219
|
+
/** O(1) lookup after the build/rehydration map is constructed. Missing keys return no rows. */
|
|
220
|
+
readonly lookup: (value: Frontmatter[By]) => ReadonlyArray<Entry<Frontmatter>>
|
|
221
|
+
/** All entries in the index's default stable order. */
|
|
222
|
+
readonly all: () => ReadonlyArray<Entry<Frontmatter>>
|
|
223
|
+
readonly query: (options?: IndexQueryOptions<Frontmatter>) => IndexPage<Frontmatter>
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Keys whose frontmatter value types are identical on both sides of a join. */
|
|
227
|
+
export type SharedKey<
|
|
228
|
+
Left extends Record<string, unknown>,
|
|
229
|
+
Right extends Record<string, unknown>,
|
|
230
|
+
> = {
|
|
231
|
+
[K in ContentFieldKey<Left> & ContentFieldKey<Right>]: [Left[K]] extends [Right[K]]
|
|
232
|
+
? [Right[K]] extends [Left[K]]
|
|
233
|
+
? K
|
|
234
|
+
: never
|
|
235
|
+
: never
|
|
236
|
+
}[ContentFieldKey<Left> & ContentFieldKey<Right>]
|
|
237
|
+
|
|
238
|
+
export type JoinCardinality = "one-to-many" | "one-to-one"
|
|
239
|
+
|
|
240
|
+
export type JoinedRow<
|
|
241
|
+
Left extends Record<string, unknown>,
|
|
242
|
+
Right extends Record<string, unknown>,
|
|
243
|
+
Cardinality extends JoinCardinality,
|
|
244
|
+
> = {
|
|
245
|
+
readonly left: Entry<Left>
|
|
246
|
+
readonly right: Cardinality extends "one-to-one"
|
|
247
|
+
? Entry<Right> | null
|
|
248
|
+
: ReadonlyArray<Entry<Right>>
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** JSON-safe joined rows, ordered by the left index and then the right index. */
|
|
252
|
+
export interface BakedCollectionJoin<
|
|
253
|
+
Left extends Record<string, unknown>,
|
|
254
|
+
Right extends Record<string, unknown>,
|
|
255
|
+
On extends SharedKey<Left, Right>,
|
|
256
|
+
Cardinality extends JoinCardinality,
|
|
257
|
+
> {
|
|
258
|
+
readonly on: On
|
|
259
|
+
readonly cardinality: Cardinality
|
|
260
|
+
readonly rows: ReadonlyArray<JoinedRow<Left, Right, Cardinality>>
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A deterministic cross-collection join. No database or network layer is involved. */
|
|
264
|
+
export interface IndexedJoin<
|
|
265
|
+
Left extends Record<string, unknown>,
|
|
266
|
+
Right extends Record<string, unknown>,
|
|
267
|
+
On extends SharedKey<Left, Right>,
|
|
268
|
+
Cardinality extends JoinCardinality,
|
|
269
|
+
> {
|
|
270
|
+
readonly entries: ReadonlyArray<JoinedRow<Left, Right, Cardinality>>
|
|
271
|
+
readonly baked: BakedCollectionJoin<Left, Right, On, Cardinality>
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface IndexOptions<
|
|
275
|
+
Frontmatter extends Record<string, unknown>,
|
|
276
|
+
By extends ContentFieldKey<Frontmatter>,
|
|
277
|
+
> {
|
|
278
|
+
readonly by: By
|
|
279
|
+
readonly sort?: IndexSort<Frontmatter>
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const DEFAULT_INDEX_PAGE_SIZE = 20
|
|
283
|
+
const MAX_INDEX_PAGE_SIZE = 100
|
|
284
|
+
const MAX_CURSOR_LENGTH = 4096
|
|
285
|
+
|
|
286
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
287
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
288
|
+
|
|
289
|
+
/** Stable JSON for frontmatter keys/filter fingerprints. It rejects values JSON cannot preserve. */
|
|
290
|
+
const stableJson = (value: unknown, seen = new Set<object>()): string | undefined => {
|
|
291
|
+
if (value === undefined) return undefined
|
|
292
|
+
if (value === null) return "null"
|
|
293
|
+
if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value)
|
|
294
|
+
if (typeof value === "number") {
|
|
295
|
+
if (!Number.isFinite(value)) throw new TypeError("non-finite number")
|
|
296
|
+
return JSON.stringify(value)
|
|
297
|
+
}
|
|
298
|
+
if (typeof value !== "object") throw new TypeError("non-JSON value")
|
|
299
|
+
if (seen.has(value)) throw new TypeError("cyclic value")
|
|
300
|
+
seen.add(value)
|
|
301
|
+
try {
|
|
302
|
+
if (Array.isArray(value)) {
|
|
303
|
+
const items = value.map((item) => {
|
|
304
|
+
const encoded = stableJson(item, seen)
|
|
305
|
+
if (encoded === undefined) throw new TypeError("undefined array item")
|
|
306
|
+
return encoded
|
|
307
|
+
})
|
|
308
|
+
return `[${items.join(",")}]`
|
|
309
|
+
}
|
|
310
|
+
const prototype = Object.getPrototypeOf(value)
|
|
311
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
312
|
+
throw new TypeError("non-plain object")
|
|
313
|
+
const fields: string[] = []
|
|
314
|
+
for (const key of Object.keys(value).sort()) {
|
|
315
|
+
const encoded = stableJson(Reflect.get(value, key), seen)
|
|
316
|
+
if (encoded === undefined) throw new TypeError("undefined object field")
|
|
317
|
+
fields.push(`${JSON.stringify(key)}:${encoded}`)
|
|
318
|
+
}
|
|
319
|
+
return `{${fields.join(",")}}`
|
|
320
|
+
} finally {
|
|
321
|
+
seen.delete(value)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const keyFor = (value: unknown, field: string): string | undefined => {
|
|
326
|
+
try {
|
|
327
|
+
return stableJson(value)
|
|
328
|
+
} catch {
|
|
329
|
+
throw new TypeError(`@nifrajs/content: field "${field}" is not JSON-indexable`)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const compareValues = (left: unknown, right: unknown): number => {
|
|
334
|
+
if (left === right) return 0
|
|
335
|
+
if (left === undefined) return 1
|
|
336
|
+
if (right === undefined) return -1
|
|
337
|
+
if (typeof left === "number" && typeof right === "number") return left < right ? -1 : 1
|
|
338
|
+
if (typeof left === "string" && typeof right === "string") return left < right ? -1 : 1
|
|
339
|
+
if (typeof left === "boolean" && typeof right === "boolean") return left ? 1 : -1
|
|
340
|
+
const leftKey = stableJson(left) ?? ""
|
|
341
|
+
const rightKey = stableJson(right) ?? ""
|
|
342
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const compareEntries = <Frontmatter extends Record<string, unknown>>(
|
|
346
|
+
entries: ReadonlyArray<Entry<Frontmatter>>,
|
|
347
|
+
leftIndex: number,
|
|
348
|
+
rightIndex: number,
|
|
349
|
+
sort: IndexSort<Frontmatter>,
|
|
350
|
+
): number => {
|
|
351
|
+
const left = entries[leftIndex]
|
|
352
|
+
const right = entries[rightIndex]
|
|
353
|
+
if (left === undefined || right === undefined) return left === undefined ? 1 : -1
|
|
354
|
+
let result = compareValues(left.frontmatter[sort.field], right.frontmatter[sort.field])
|
|
355
|
+
if (sort.dir === "desc") result = -result
|
|
356
|
+
if (result !== 0) return result
|
|
357
|
+
result = left.slug < right.slug ? -1 : left.slug > right.slug ? 1 : 0
|
|
358
|
+
return result !== 0 ? result : leftIndex - rightIndex
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const sortedOrder = <Frontmatter extends Record<string, unknown>>(
|
|
362
|
+
entries: ReadonlyArray<Entry<Frontmatter>>,
|
|
363
|
+
sort: IndexSort<Frontmatter>,
|
|
364
|
+
): number[] =>
|
|
365
|
+
entries.map((_, index) => index).sort((left, right) => compareEntries(entries, left, right, sort))
|
|
366
|
+
|
|
367
|
+
const assertPageSize = (value: number | undefined): number => {
|
|
368
|
+
const limit = value ?? DEFAULT_INDEX_PAGE_SIZE
|
|
369
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_INDEX_PAGE_SIZE) {
|
|
370
|
+
throw new RangeError(
|
|
371
|
+
`@nifrajs/content: index page limit must be an integer from 1 to ${MAX_INDEX_PAGE_SIZE}`,
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
return limit
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const filterFingerprint = (where: unknown, range: unknown): string => {
|
|
378
|
+
try {
|
|
379
|
+
return stableJson({ where: where ?? null, range: range ?? null }) ?? "null"
|
|
380
|
+
} catch {
|
|
381
|
+
throw new TypeError("@nifrajs/content: index filters must contain JSON values")
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
interface CursorPayload {
|
|
386
|
+
readonly v: 1
|
|
387
|
+
readonly sortField: string
|
|
388
|
+
readonly dir: "asc" | "desc"
|
|
389
|
+
readonly filter: string
|
|
390
|
+
readonly position: number
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const encodeCursor = (payload: CursorPayload): string => encodeURIComponent(JSON.stringify(payload))
|
|
394
|
+
|
|
395
|
+
const decodeCursor = (cursor: string): CursorPayload => {
|
|
396
|
+
if (cursor.length === 0 || cursor.length > MAX_CURSOR_LENGTH) {
|
|
397
|
+
throw new TypeError("@nifrajs/content: invalid index cursor")
|
|
398
|
+
}
|
|
399
|
+
let parsed: unknown
|
|
400
|
+
try {
|
|
401
|
+
parsed = JSON.parse(decodeURIComponent(cursor))
|
|
402
|
+
} catch {
|
|
403
|
+
throw new TypeError("@nifrajs/content: invalid index cursor")
|
|
404
|
+
}
|
|
405
|
+
if (!isRecord(parsed)) throw new TypeError("@nifrajs/content: invalid index cursor")
|
|
406
|
+
const version = parsed.v
|
|
407
|
+
const sortField = parsed.sortField
|
|
408
|
+
const dir = parsed.dir
|
|
409
|
+
const filter = parsed.filter
|
|
410
|
+
const position = parsed.position
|
|
411
|
+
if (
|
|
412
|
+
version !== 1 ||
|
|
413
|
+
typeof sortField !== "string" ||
|
|
414
|
+
(dir !== "asc" && dir !== "desc") ||
|
|
415
|
+
typeof filter !== "string" ||
|
|
416
|
+
typeof position !== "number" ||
|
|
417
|
+
!Number.isSafeInteger(position) ||
|
|
418
|
+
position < -1
|
|
419
|
+
) {
|
|
420
|
+
throw new TypeError("@nifrajs/content: invalid index cursor")
|
|
421
|
+
}
|
|
422
|
+
return { v: 1, sortField, dir, filter, position }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const createIndexedCollection = <
|
|
426
|
+
Frontmatter extends Record<string, unknown>,
|
|
427
|
+
By extends ContentFieldKey<Frontmatter>,
|
|
428
|
+
>(
|
|
429
|
+
baked: BakedCollectionIndex<Frontmatter, By>,
|
|
430
|
+
): IndexedCollection<Frontmatter, By> => {
|
|
431
|
+
if (
|
|
432
|
+
typeof baked.by !== "string" ||
|
|
433
|
+
!isRecord(baked.sort) ||
|
|
434
|
+
typeof baked.sort.field !== "string" ||
|
|
435
|
+
(baked.sort.dir !== "asc" && baked.sort.dir !== "desc") ||
|
|
436
|
+
!Array.isArray(baked.entries) ||
|
|
437
|
+
!Array.isArray(baked.order) ||
|
|
438
|
+
!Array.isArray(baked.buckets)
|
|
439
|
+
) {
|
|
440
|
+
throw new TypeError("@nifrajs/content: malformed baked index")
|
|
441
|
+
}
|
|
442
|
+
const order = [...baked.order]
|
|
443
|
+
const seenIndexes = new Set<number>()
|
|
444
|
+
for (const index of order) {
|
|
445
|
+
if (
|
|
446
|
+
!Number.isSafeInteger(index) ||
|
|
447
|
+
index < 0 ||
|
|
448
|
+
index >= baked.entries.length ||
|
|
449
|
+
seenIndexes.has(index)
|
|
450
|
+
) {
|
|
451
|
+
throw new TypeError("@nifrajs/content: malformed baked index order")
|
|
452
|
+
}
|
|
453
|
+
seenIndexes.add(index)
|
|
454
|
+
}
|
|
455
|
+
if (seenIndexes.size !== baked.entries.length) {
|
|
456
|
+
throw new TypeError("@nifrajs/content: malformed baked index order")
|
|
457
|
+
}
|
|
458
|
+
const lookup = new Map<string, number[]>()
|
|
459
|
+
const bucketIndexes = new Set<number>()
|
|
460
|
+
for (const bucket of baked.buckets) {
|
|
461
|
+
if (
|
|
462
|
+
!isRecord(bucket) ||
|
|
463
|
+
typeof bucket.key !== "string" ||
|
|
464
|
+
!Array.isArray(bucket.entryIndexes)
|
|
465
|
+
) {
|
|
466
|
+
throw new TypeError("@nifrajs/content: malformed baked index bucket")
|
|
467
|
+
}
|
|
468
|
+
if (lookup.has(bucket.key))
|
|
469
|
+
throw new TypeError("@nifrajs/content: duplicate baked index bucket")
|
|
470
|
+
const indexes: number[] = []
|
|
471
|
+
for (const index of bucket.entryIndexes) {
|
|
472
|
+
if (!Number.isSafeInteger(index) || !seenIndexes.has(index)) {
|
|
473
|
+
throw new TypeError("@nifrajs/content: malformed baked index bucket")
|
|
474
|
+
}
|
|
475
|
+
if (bucketIndexes.has(index)) {
|
|
476
|
+
throw new TypeError("@nifrajs/content: duplicate baked index entry")
|
|
477
|
+
}
|
|
478
|
+
bucketIndexes.add(index)
|
|
479
|
+
indexes.push(index)
|
|
480
|
+
}
|
|
481
|
+
lookup.set(bucket.key, indexes)
|
|
482
|
+
}
|
|
483
|
+
const entries = baked.entries
|
|
484
|
+
const orderedEntries = (): ReadonlyArray<Entry<Frontmatter>> =>
|
|
485
|
+
order.flatMap((index) => {
|
|
486
|
+
const entry = entries[index]
|
|
487
|
+
return entry === undefined ? [] : [entry]
|
|
488
|
+
})
|
|
489
|
+
const all = (): ReadonlyArray<Entry<Frontmatter>> => orderedEntries()
|
|
490
|
+
const query = (options: IndexQueryOptions<Frontmatter> = {}): IndexPage<Frontmatter> => {
|
|
491
|
+
const limit = assertPageSize(options.limit)
|
|
492
|
+
const where = options.where
|
|
493
|
+
const range = options.range
|
|
494
|
+
if (where !== undefined && !isRecord(where)) {
|
|
495
|
+
throw new TypeError("@nifrajs/content: index where must be an object")
|
|
496
|
+
}
|
|
497
|
+
if (range !== undefined && !isRecord(range)) {
|
|
498
|
+
throw new TypeError("@nifrajs/content: index range must be an object")
|
|
499
|
+
}
|
|
500
|
+
const sort = options.sort ?? baked.sort
|
|
501
|
+
if (
|
|
502
|
+
!isRecord(sort) ||
|
|
503
|
+
typeof sort.field !== "string" ||
|
|
504
|
+
(sort.dir !== "asc" && sort.dir !== "desc")
|
|
505
|
+
) {
|
|
506
|
+
throw new TypeError("@nifrajs/content: malformed index sort")
|
|
507
|
+
}
|
|
508
|
+
const filter = filterFingerprint(where, range)
|
|
509
|
+
const queryOrder =
|
|
510
|
+
sort.field === baked.sort.field && sort.dir === baked.sort.dir
|
|
511
|
+
? order
|
|
512
|
+
: sortedOrder(entries, sort)
|
|
513
|
+
const filtered = queryOrder.filter((index) => {
|
|
514
|
+
const entry = entries[index]
|
|
515
|
+
if (entry === undefined) return false
|
|
516
|
+
const frontmatter: Record<string, unknown> = entry.frontmatter
|
|
517
|
+
if (where !== undefined) {
|
|
518
|
+
for (const [field, expected] of Object.entries(where)) {
|
|
519
|
+
if (!Object.hasOwn(frontmatter, field)) return false
|
|
520
|
+
if (keyFor(frontmatter[field], field) !== keyFor(expected, field)) return false
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (range !== undefined) {
|
|
524
|
+
for (const [field, rawRule] of Object.entries(range)) {
|
|
525
|
+
if (!Object.hasOwn(frontmatter, field) || !isRecord(rawRule)) {
|
|
526
|
+
throw new TypeError("@nifrajs/content: malformed index range")
|
|
527
|
+
}
|
|
528
|
+
const actual = frontmatter[field]
|
|
529
|
+
if (
|
|
530
|
+
typeof actual !== "string" &&
|
|
531
|
+
(typeof actual !== "number" || !Number.isFinite(actual))
|
|
532
|
+
) {
|
|
533
|
+
return false
|
|
534
|
+
}
|
|
535
|
+
for (const operator of ["gt", "gte", "lt", "lte"] as const) {
|
|
536
|
+
if (!Object.hasOwn(rawRule, operator)) continue
|
|
537
|
+
const expected = rawRule[operator]
|
|
538
|
+
if (
|
|
539
|
+
(typeof expected !== "string" &&
|
|
540
|
+
(typeof expected !== "number" || !Number.isFinite(expected))) ||
|
|
541
|
+
typeof expected !== typeof actual
|
|
542
|
+
) {
|
|
543
|
+
throw new TypeError("@nifrajs/content: malformed index range")
|
|
544
|
+
}
|
|
545
|
+
const comparison = compareValues(actual, expected)
|
|
546
|
+
if (
|
|
547
|
+
(operator === "gt" && comparison <= 0) ||
|
|
548
|
+
(operator === "gte" && comparison < 0) ||
|
|
549
|
+
(operator === "lt" && comparison >= 0) ||
|
|
550
|
+
(operator === "lte" && comparison > 0)
|
|
551
|
+
) {
|
|
552
|
+
return false
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return true
|
|
558
|
+
})
|
|
559
|
+
const cursor = options.cursor === undefined ? undefined : decodeCursor(options.cursor)
|
|
560
|
+
let start = 0
|
|
561
|
+
if (cursor !== undefined) {
|
|
562
|
+
// A cursor is a position in ONE query's result order, so it is only meaningful against the query
|
|
563
|
+
// that issued it - a different sort or filter makes the same number address a different entry.
|
|
564
|
+
// That check is separate from running off the end: an index that shrank between pages leaves a
|
|
565
|
+
// matching cursor pointing past the last row, which is an exhausted page, not a bad cursor.
|
|
566
|
+
if (
|
|
567
|
+
cursor.sortField !== String(sort.field) ||
|
|
568
|
+
cursor.dir !== sort.dir ||
|
|
569
|
+
cursor.filter !== filter
|
|
570
|
+
) {
|
|
571
|
+
throw new TypeError("@nifrajs/content: cursor does not match this index query")
|
|
572
|
+
}
|
|
573
|
+
start = Math.min(cursor.position + 1, filtered.length)
|
|
574
|
+
}
|
|
575
|
+
const pageIndexes = filtered.slice(start, start + limit)
|
|
576
|
+
const items = pageIndexes.flatMap((index) => {
|
|
577
|
+
const entry = entries[index]
|
|
578
|
+
return entry === undefined ? [] : [entry]
|
|
579
|
+
})
|
|
580
|
+
const end = start + items.length
|
|
581
|
+
return end < filtered.length
|
|
582
|
+
? {
|
|
583
|
+
items,
|
|
584
|
+
nextCursor: encodeCursor({
|
|
585
|
+
v: 1,
|
|
586
|
+
sortField: String(sort.field),
|
|
587
|
+
dir: sort.dir,
|
|
588
|
+
filter,
|
|
589
|
+
position: end - 1,
|
|
590
|
+
}),
|
|
591
|
+
}
|
|
592
|
+
: { items }
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
entries,
|
|
596
|
+
by: baked.by,
|
|
597
|
+
baked,
|
|
598
|
+
lookup: (value) => {
|
|
599
|
+
const key = keyFor(value, String(baked.by))
|
|
600
|
+
if (key === undefined) return []
|
|
601
|
+
return (lookup.get(key) ?? []).flatMap((index) => {
|
|
602
|
+
const entry = entries[index]
|
|
603
|
+
return entry === undefined ? [] : [entry]
|
|
604
|
+
})
|
|
605
|
+
},
|
|
606
|
+
all,
|
|
607
|
+
query,
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Build a deterministic typed index from already validated baked entries. */
|
|
612
|
+
export function indexCollection<
|
|
613
|
+
Frontmatter extends Record<string, unknown>,
|
|
614
|
+
By extends ContentFieldKey<Frontmatter>,
|
|
615
|
+
>(
|
|
616
|
+
baked: BakedCollection<Frontmatter>,
|
|
617
|
+
options: IndexOptions<Frontmatter, By>,
|
|
618
|
+
): IndexedCollection<Frontmatter, By> {
|
|
619
|
+
const sort = options.sort ?? { field: options.by, dir: "asc" as const }
|
|
620
|
+
const order = sortedOrder(baked.entries, sort)
|
|
621
|
+
const buckets = new Map<string, number[]>()
|
|
622
|
+
for (const index of order) {
|
|
623
|
+
const entry = baked.entries[index]
|
|
624
|
+
if (entry === undefined) continue
|
|
625
|
+
const value = entry.frontmatter[options.by]
|
|
626
|
+
const key = keyFor(value, String(options.by))
|
|
627
|
+
if (key === undefined) continue
|
|
628
|
+
const indexes = buckets.get(key)
|
|
629
|
+
if (indexes === undefined) buckets.set(key, [index])
|
|
630
|
+
else indexes.push(index)
|
|
631
|
+
}
|
|
632
|
+
const bakedIndex: BakedCollectionIndex<Frontmatter, By> = {
|
|
633
|
+
entries: baked.entries,
|
|
634
|
+
by: options.by,
|
|
635
|
+
sort,
|
|
636
|
+
order,
|
|
637
|
+
buckets: [...buckets].map(([key, entryIndexes]) => ({ key, entryIndexes })),
|
|
638
|
+
}
|
|
639
|
+
return createIndexedCollection(bakedIndex)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Rehydrate a JSON-serialized index at the edge without filesystem access. */
|
|
643
|
+
export function fromBakedIndex<
|
|
644
|
+
Frontmatter extends Record<string, unknown>,
|
|
645
|
+
By extends ContentFieldKey<Frontmatter>,
|
|
646
|
+
>(baked: BakedCollectionIndex<Frontmatter, By>): IndexedCollection<Frontmatter, By> {
|
|
647
|
+
return createIndexedCollection(baked)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const buildJoin = <
|
|
651
|
+
Left extends Record<string, unknown>,
|
|
652
|
+
Right extends Record<string, unknown>,
|
|
653
|
+
LeftBy extends ContentFieldKey<Left>,
|
|
654
|
+
RightBy extends ContentFieldKey<Right>,
|
|
655
|
+
On extends SharedKey<Left, Right>,
|
|
656
|
+
Cardinality extends JoinCardinality,
|
|
657
|
+
>(
|
|
658
|
+
left: IndexedCollection<Left, LeftBy>,
|
|
659
|
+
right: IndexedCollection<Right, RightBy>,
|
|
660
|
+
on: On,
|
|
661
|
+
cardinality: Cardinality,
|
|
662
|
+
): IndexedJoin<Left, Right, On, Cardinality> => {
|
|
663
|
+
const rightByKey = new Map<string, Entry<Right>[]>()
|
|
664
|
+
for (const index of right.baked.order) {
|
|
665
|
+
const entry = right.entries[index]
|
|
666
|
+
if (entry === undefined) continue
|
|
667
|
+
const key = keyFor(entry.frontmatter[on], String(on))
|
|
668
|
+
if (key === undefined) continue
|
|
669
|
+
const existing = rightByKey.get(key)
|
|
670
|
+
if (existing === undefined) rightByKey.set(key, [entry])
|
|
671
|
+
else {
|
|
672
|
+
if (cardinality === "one-to-one") {
|
|
673
|
+
throw new Error(`@nifrajs/content: duplicate right join key for "${String(on)}"`)
|
|
674
|
+
}
|
|
675
|
+
existing.push(entry)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const rows: Array<JoinedRow<Left, Right, Cardinality>> = []
|
|
679
|
+
for (const index of left.baked.order) {
|
|
680
|
+
const entry = left.entries[index]
|
|
681
|
+
if (entry === undefined) continue
|
|
682
|
+
const key = keyFor(entry.frontmatter[on], String(on))
|
|
683
|
+
const matches = key === undefined ? undefined : rightByKey.get(key)
|
|
684
|
+
rows.push({
|
|
685
|
+
left: entry,
|
|
686
|
+
right: (cardinality === "one-to-one"
|
|
687
|
+
? (matches?.[0] ?? null)
|
|
688
|
+
: [...(matches ?? [])]) as JoinedRow<Left, Right, Cardinality>["right"],
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
const baked: BakedCollectionJoin<Left, Right, On, Cardinality> = {
|
|
692
|
+
on,
|
|
693
|
+
cardinality,
|
|
694
|
+
rows,
|
|
695
|
+
}
|
|
696
|
+
return { entries: rows, baked }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Join two indexes on a real same-typed frontmatter key. Default cardinality is one-to-many. */
|
|
700
|
+
export function joinCollections<
|
|
701
|
+
Left extends Record<string, unknown>,
|
|
702
|
+
Right extends Record<string, unknown>,
|
|
703
|
+
LeftBy extends ContentFieldKey<Left>,
|
|
704
|
+
RightBy extends ContentFieldKey<Right>,
|
|
705
|
+
On extends SharedKey<Left, Right>,
|
|
706
|
+
>(
|
|
707
|
+
left: IndexedCollection<Left, LeftBy>,
|
|
708
|
+
right: IndexedCollection<Right, RightBy>,
|
|
709
|
+
on: On,
|
|
710
|
+
options?: { readonly cardinality?: "one-to-many" },
|
|
711
|
+
): IndexedJoin<Left, Right, On, "one-to-many">
|
|
712
|
+
export function joinCollections<
|
|
713
|
+
Left extends Record<string, unknown>,
|
|
714
|
+
Right extends Record<string, unknown>,
|
|
715
|
+
LeftBy extends ContentFieldKey<Left>,
|
|
716
|
+
RightBy extends ContentFieldKey<Right>,
|
|
717
|
+
On extends SharedKey<Left, Right>,
|
|
718
|
+
>(
|
|
719
|
+
left: IndexedCollection<Left, LeftBy>,
|
|
720
|
+
right: IndexedCollection<Right, RightBy>,
|
|
721
|
+
on: On,
|
|
722
|
+
options: { readonly cardinality: "one-to-one" },
|
|
723
|
+
): IndexedJoin<Left, Right, On, "one-to-one">
|
|
724
|
+
export function joinCollections<
|
|
725
|
+
Left extends Record<string, unknown>,
|
|
726
|
+
Right extends Record<string, unknown>,
|
|
727
|
+
LeftBy extends ContentFieldKey<Left>,
|
|
728
|
+
RightBy extends ContentFieldKey<Right>,
|
|
729
|
+
On extends SharedKey<Left, Right>,
|
|
730
|
+
>(
|
|
731
|
+
left: IndexedCollection<Left, LeftBy>,
|
|
732
|
+
right: IndexedCollection<Right, RightBy>,
|
|
733
|
+
on: On,
|
|
734
|
+
options?: { readonly cardinality?: JoinCardinality },
|
|
735
|
+
): IndexedJoin<Left, Right, On, JoinCardinality> {
|
|
736
|
+
const cardinality = options?.cardinality ?? "one-to-many"
|
|
737
|
+
return buildJoin(left, right, on, cardinality)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Rehydrate a deterministic join artifact. */
|
|
741
|
+
export function fromBakedJoin<
|
|
742
|
+
Left extends Record<string, unknown>,
|
|
743
|
+
Right extends Record<string, unknown>,
|
|
744
|
+
On extends SharedKey<Left, Right>,
|
|
745
|
+
Cardinality extends JoinCardinality,
|
|
746
|
+
>(
|
|
747
|
+
baked: BakedCollectionJoin<Left, Right, On, Cardinality>,
|
|
748
|
+
): IndexedJoin<Left, Right, On, Cardinality> {
|
|
749
|
+
if (!isRecord(baked) || !Array.isArray(baked.rows)) {
|
|
750
|
+
throw new TypeError("@nifrajs/content: malformed baked join")
|
|
751
|
+
}
|
|
752
|
+
if (baked.cardinality !== "one-to-many" && baked.cardinality !== "one-to-one") {
|
|
753
|
+
throw new TypeError("@nifrajs/content: malformed baked join")
|
|
754
|
+
}
|
|
755
|
+
for (const row of baked.rows) {
|
|
756
|
+
if (!isRecord(row) || !isRecord(row.left)) {
|
|
757
|
+
throw new TypeError("@nifrajs/content: malformed baked join row")
|
|
758
|
+
}
|
|
759
|
+
const validRight =
|
|
760
|
+
baked.cardinality === "one-to-many"
|
|
761
|
+
? Array.isArray(row.right)
|
|
762
|
+
: row.right === null || isRecord(row.right)
|
|
763
|
+
if (!validRight) throw new TypeError("@nifrajs/content: malformed baked join row")
|
|
764
|
+
}
|
|
765
|
+
return { entries: baked.rows, baked }
|
|
766
|
+
}
|