@notion-headless-cms/core 0.3.12 → 0.3.14

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/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { memoryCache } from "./cache/memory.mjs";
2
- import { CMSError, isCMSError, isCMSErrorInNamespace } from "./errors.mjs";
2
+ import { CMSError, isCMSError, isCMSErrorInNamespace, matchCMSError } from "./errors.mjs";
3
3
  import { mergeHooks, mergeLoggers } from "./hooks.mjs";
4
4
  //#region src/cache.ts
5
5
  /** 文字列をSHA-256でハッシュ化し、16進数文字列として返す。画像キーの生成に使用。 */
@@ -59,44 +59,37 @@ const noopDocOps = noopDoc;
59
59
  const noopImgOps = noopImg;
60
60
  //#endregion
61
61
  //#region src/image.ts
62
- /** レスポンスヘッダまたはURLの拡張子からContent-Typeを推測する。 */
63
- function inferContentType(url, responseContentType) {
64
- if (responseContentType?.startsWith("image/")) return responseContentType.split(";")[0].trim();
65
- if (url.includes(".png")) return "image/png";
66
- if (url.includes(".gif")) return "image/gif";
67
- if (url.includes(".webp")) return "image/webp";
68
- return "image/jpeg";
69
- }
70
62
  /**
71
- * URL → SHA-256 hash のメモ化マップ。
72
- * Notion の画像 URL は同じ画像でも署名が時刻ごとに変わるが、
73
- * 1 リクエスト内では同一 URL が複数回現れることが多い (重複ハッシュ計算を回避)。
74
- *
75
- * メモリリーク防止に最大エントリ数を設けており、超過時は最古から削除する LRU。
63
+ * レスポンスの Content-Type ヘッダから画像の MIME タイプを取り出す。
64
+ * ヘッダがない、または image/* でない場合は CMSError を投げる。
65
+ * URL 拡張子からの推測や jpeg デフォルトは行わない。
76
66
  */
77
- const HASH_MEMO_LIMIT = 1024;
78
- const hashMemo = /* @__PURE__ */ new Map();
79
- async function memoSha256(url) {
80
- const cached = hashMemo.get(url);
81
- if (cached !== void 0) {
82
- hashMemo.delete(url);
83
- hashMemo.set(url, cached);
84
- return cached;
85
- }
86
- const hash = await sha256Hex(url);
87
- hashMemo.set(url, hash);
88
- if (hashMemo.size > HASH_MEMO_LIMIT) {
89
- const firstKey = hashMemo.keys().next().value;
90
- if (firstKey !== void 0) hashMemo.delete(firstKey);
91
- }
92
- return hash;
67
+ function pickImageContentType(headerValue, notionUrl) {
68
+ if (!headerValue) throw new CMSError({
69
+ code: "cache/image_invalid_content_type",
70
+ message: "Image response missing Content-Type header.",
71
+ context: {
72
+ operation: "fetchAndCacheImage:contentType",
73
+ notionUrl
74
+ }
75
+ });
76
+ const value = (headerValue.split(";")[0] ?? headerValue).trim().toLowerCase();
77
+ if (!value.startsWith("image/")) throw new CMSError({
78
+ code: "cache/image_invalid_content_type",
79
+ message: `Image response has non-image Content-Type: ${value}`,
80
+ context: {
81
+ operation: "fetchAndCacheImage:contentType",
82
+ notionUrl,
83
+ contentType: value
84
+ }
85
+ });
86
+ return value;
93
87
  }
94
88
  /**
95
89
  * Notion画像URLをfetchして ImageCacheOps にキャッシュし、プロキシURL を返す。
96
90
  * 既存キャッシュがあれば再fetchしない。
97
91
  */
98
- async function fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, logger) {
99
- const hash = await memoSha256(notionUrl);
92
+ async function fetchAndCacheImage(cache, cacheName, notionUrl, hash, imageProxyBase, logger) {
100
93
  const proxyUrl = `${imageProxyBase}/${hash}`;
101
94
  if (await cache.get(hash)) {
102
95
  logger?.debug?.("画像キャッシュヒット", {
@@ -123,7 +116,7 @@ async function fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, l
123
116
  }
124
117
  });
125
118
  const data = await response.arrayBuffer();
126
- const contentType = inferContentType(notionUrl, response.headers.get("content-type"));
119
+ const contentType = pickImageContentType(response.headers.get("content-type"), notionUrl);
127
120
  await cache.set(hash, data, contentType);
128
121
  logger?.debug?.("画像をキャッシュに保存", {
129
122
  operation: "fetchAndCacheImage",
@@ -148,13 +141,39 @@ async function fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, l
148
141
  * `ImageCacheOps` と `imageProxyBase` から `cacheImage` 関数を構築する。
149
142
  * 返り値は Notion 画像 URL を受け取り、SHA-256 ハッシュをキャッシュキーとして
150
143
  * {@link ImageCacheOps} に保存後、プロキシ URL を返す。
144
+ *
145
+ * ハッシュのメモ化はファクトリ呼び出し単位でスコープ化されており、
146
+ * インスタンス間でキャッシュを共有しない。
151
147
  */
152
148
  function buildCacheImageFn(cache, cacheName, imageProxyBase, logger) {
153
- return (notionUrl) => fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, logger);
149
+ const hashMemo = /* @__PURE__ */ new Map();
150
+ return async (notionUrl) => {
151
+ let hash = hashMemo.get(notionUrl);
152
+ if (hash === void 0) {
153
+ hash = await sha256Hex(notionUrl);
154
+ hashMemo.set(notionUrl, hash);
155
+ }
156
+ return fetchAndCacheImage(cache, cacheName, notionUrl, hash, imageProxyBase, logger);
157
+ };
154
158
  }
155
159
  //#endregion
156
160
  //#region src/rendering.ts
157
161
  /**
162
+ * `@notion-headless-cms/renderer` を動的 import してデフォルトレンダラーを返す。
163
+ * core のゼロ依存ルールを守るため静的 import は禁止。
164
+ */
165
+ async function loadDefaultRenderer() {
166
+ try {
167
+ return (await import("@notion-headless-cms/renderer")).renderMarkdown;
168
+ } catch {
169
+ throw new CMSError({
170
+ code: "core/config_invalid",
171
+ message: "renderer が未指定で、@notion-headless-cms/renderer のロードにも失敗しました。 createCMS の renderer オプションを指定するか、@notion-headless-cms/renderer をインストールしてください。",
172
+ context: { operation: "loadDefaultRenderer" }
173
+ });
174
+ }
175
+ }
176
+ /**
158
177
  * メタデータキャッシュエントリを生成する。Notion API も renderer も呼ばない軽量関数。
159
178
  */
160
179
  function buildCachedItemMeta(item, source) {
@@ -191,19 +210,25 @@ async function buildCachedItemContent(item, ctx) {
191
210
  }
192
211
  });
193
212
  }
194
- let blocks = [];
213
+ let blocks;
195
214
  try {
196
215
  blocks = await ctx.source.loadBlocks(item);
197
216
  } catch (err) {
198
- ctx.logger?.warn?.("loadBlocks に失敗したため raw フォールバック", {
199
- slug: item.slug,
200
- error: err instanceof Error ? err.message : String(err)
217
+ if (isCMSError(err)) throw err;
218
+ throw new CMSError({
219
+ code: "source/load_blocks_failed",
220
+ message: "Failed to load blocks from source.",
221
+ cause: err,
222
+ context: {
223
+ operation: "buildCachedItemContent:loadBlocks",
224
+ pageId: item.id,
225
+ slug: item.slug
226
+ }
201
227
  });
202
- blocks = [];
203
228
  }
204
229
  const cacheImage = ctx.hasImageCache ? buildCacheImageFn(ctx.imgCache, ctx.imgCacheName, ctx.imageProxyBase, ctx.logger) : void 0;
205
- let html;
206
230
  const rendererFn = ctx.rendererFn ?? await loadDefaultRenderer();
231
+ let html;
207
232
  try {
208
233
  html = await rendererFn(markdown, {
209
234
  imageProxyBase: ctx.imageProxyBase,
@@ -241,23 +266,6 @@ async function buildCachedItemContent(item, ctx) {
241
266
  ctx.hooks.onRenderEnd?.(item.slug, durationMs);
242
267
  return result;
243
268
  }
244
- /**
245
- * renderer オプション未指定時のフォールバック。
246
- * @notion-headless-cms/renderer を動的 import する。
247
- * createCMS({ renderer }) で明示注入された場合はこのパスを通らない。
248
- */
249
- async function loadDefaultRenderer() {
250
- try {
251
- return (await import("@notion-headless-cms/renderer")).renderMarkdown;
252
- } catch (err) {
253
- throw new CMSError({
254
- code: "renderer/failed",
255
- message: "renderer オプションが未指定で @notion-headless-cms/renderer が見つかりません。 createCMS({ renderer }) でレンダラーを注入するか、@notion-headless-cms/renderer をインストールしてください。",
256
- cause: err,
257
- context: { operation: "loadDefaultRenderer" }
258
- });
259
- }
260
- }
261
269
  //#endregion
262
270
  //#region src/retry.ts
263
271
  const DEFAULT_RETRY_CONFIG = {
@@ -313,40 +321,40 @@ var CollectionClientImpl = class {
313
321
  constructor(ctx) {
314
322
  this.ctx = ctx;
315
323
  this.cache = {
316
- invalidate: (slug) => this.invalidateImpl(slug),
317
- warm: (opts) => this.warmImpl(opts),
318
- adjacent: (slug, opts) => this.adjacentImpl(slug, opts)
324
+ invalidate: () => this.invalidateImpl(),
325
+ invalidateItem: (slug) => this.invalidateItemImpl(slug),
326
+ warm: (opts) => this.warmImpl(opts)
319
327
  };
320
328
  }
321
- async get(slug, opts = {}) {
322
- if (opts.fresh) {
329
+ async find(slug, opts = {}) {
330
+ if (opts.bypassCache) {
323
331
  this.ctx.hooks.onCacheMiss?.(slug);
324
- const item = await this.findRaw(slug);
332
+ const item = await this.fetchRaw(slug);
325
333
  if (!item) return null;
326
334
  const meta = await this.persistMeta(slug, item);
327
- await this.invalidateContent(slug);
335
+ await this.invalidateContentEntry(slug);
328
336
  return this.attachLazyContent(meta);
329
337
  }
330
338
  const cachedMeta = await this.ctx.docCache.getMeta(this.ctx.collection, slug);
331
339
  if (cachedMeta) {
332
340
  if (this.ctx.ttlMs !== void 0 && isStale(cachedMeta.cachedAt, this.ctx.ttlMs)) {
333
341
  this.ctx.logger?.debug?.("キャッシュ期限切れ(TTL)、フェッチ", {
334
- operation: "get",
342
+ operation: "find",
335
343
  slug,
336
344
  collection: this.ctx.collection,
337
345
  cacheAdapter: this.ctx.docCacheName
338
346
  });
339
347
  this.ctx.hooks.onCacheMiss?.(slug);
340
- const item = await this.findRaw(slug);
348
+ const item = await this.fetchRaw(slug);
341
349
  if (!item) return null;
342
350
  const meta = await this.persistMeta(slug, item);
343
- await this.invalidateContent(slug);
351
+ await this.invalidateContentEntry(slug);
344
352
  return this.attachLazyContent(meta);
345
353
  }
346
354
  const bg = this.checkAndUpdateItemBg(slug, cachedMeta);
347
355
  if (this.ctx.waitUntil) this.ctx.waitUntil(bg);
348
356
  this.ctx.logger?.debug?.("キャッシュヒット", {
349
- operation: "get",
357
+ operation: "find",
350
358
  slug,
351
359
  collection: this.ctx.collection,
352
360
  cacheAdapter: this.ctx.docCacheName,
@@ -356,13 +364,13 @@ var CollectionClientImpl = class {
356
364
  return this.attachLazyContent(cachedMeta);
357
365
  }
358
366
  this.ctx.logger?.debug?.("キャッシュミス、フェッチ", {
359
- operation: "get",
367
+ operation: "find",
360
368
  slug,
361
369
  collection: this.ctx.collection,
362
370
  cacheAdapter: this.ctx.docCacheName
363
371
  });
364
372
  this.ctx.hooks.onCacheMiss?.(slug);
365
- const item = await this.findRaw(slug);
373
+ const item = await this.fetchRaw(slug);
366
374
  if (!item) return null;
367
375
  const meta = await this.persistMeta(slug, item, { background: true });
368
376
  return this.attachLazyContent(meta);
@@ -371,33 +379,32 @@ var CollectionClientImpl = class {
371
379
  return applyListOptions(await this.fetchList(), opts);
372
380
  }
373
381
  async params() {
374
- return (await this.fetchList()).map((item) => ({ slug: item.slug }));
382
+ return (await this.fetchList()).map((item) => item.slug);
375
383
  }
376
384
  async check(slug, currentVersion) {
377
- const raw = await this.findRaw(slug);
385
+ const raw = await this.fetchRaw(slug);
378
386
  if (!raw) return null;
379
- if (raw.updatedAt === currentVersion) return { stale: false };
387
+ if (raw.lastEditedTime === currentVersion) return { stale: false };
380
388
  const meta = await this.persistMeta(slug, raw);
381
- await this.invalidateContent(slug);
389
+ await this.invalidateContentEntry(slug);
382
390
  return {
383
391
  stale: true,
384
392
  item: this.attachLazyContent(meta)
385
393
  };
386
394
  }
387
- async invalidateImpl(slug) {
388
- if (slug !== void 0) {
389
- this.ctx.logger?.debug?.("アイテムキャッシュを無効化", {
390
- operation: "cache.invalidate",
391
- collection: this.ctx.collection,
392
- cacheAdapter: this.ctx.docCacheName,
393
- slug
394
- });
395
- await this.ctx.docCache.invalidate({
396
- collection: this.ctx.collection,
397
- slug
398
- });
399
- return;
400
- }
395
+ async adjacent(slug, opts) {
396
+ const items = applyListOptions(await this.fetchList(), { sort: opts?.sort });
397
+ const index = items.findIndex((it) => it.slug === slug);
398
+ if (index === -1) return {
399
+ prev: null,
400
+ next: null
401
+ };
402
+ return {
403
+ prev: index > 0 ? items[index - 1] ?? null : null,
404
+ next: index < items.length - 1 ? items[index + 1] ?? null : null
405
+ };
406
+ }
407
+ async invalidateImpl() {
401
408
  this.ctx.logger?.debug?.("コレクション全体のキャッシュを無効化", {
402
409
  operation: "cache.invalidate",
403
410
  collection: this.ctx.collection,
@@ -405,11 +412,23 @@ var CollectionClientImpl = class {
405
412
  });
406
413
  await this.ctx.docCache.invalidate({ collection: this.ctx.collection });
407
414
  }
415
+ async invalidateItemImpl(slug) {
416
+ this.ctx.logger?.debug?.("アイテムキャッシュを無効化", {
417
+ operation: "cache.invalidateItem",
418
+ collection: this.ctx.collection,
419
+ cacheAdapter: this.ctx.docCacheName,
420
+ slug
421
+ });
422
+ await this.ctx.docCache.invalidate({
423
+ collection: this.ctx.collection,
424
+ slug
425
+ });
426
+ }
408
427
  async warmImpl(opts) {
409
428
  const items = await this.fetchListRaw();
410
429
  const concurrency = opts?.concurrency ?? this.ctx.maxConcurrent;
411
430
  let ok = 0;
412
- let failed = 0;
431
+ const failed = [];
413
432
  for (let i = 0; i < items.length; i += concurrency) {
414
433
  const chunk = items.slice(i, i + concurrency);
415
434
  await Promise.all(chunk.map(async (item) => {
@@ -419,7 +438,10 @@ var CollectionClientImpl = class {
419
438
  await this.ctx.docCache.setContent(this.ctx.collection, item.slug, content);
420
439
  ok++;
421
440
  } catch (err) {
422
- failed++;
441
+ failed.push({
442
+ slug: item.slug,
443
+ error: err
444
+ });
423
445
  this.ctx.logger?.warn?.("warm: アイテムの事前レンダリングに失敗", {
424
446
  slug: item.slug,
425
447
  pageId: item.id,
@@ -438,18 +460,6 @@ var CollectionClientImpl = class {
438
460
  failed
439
461
  };
440
462
  }
441
- async adjacentImpl(slug, opts) {
442
- const items = applyListOptions(await this.fetchList(), { sort: opts?.sort });
443
- const index = items.findIndex((it) => it.slug === slug);
444
- if (index === -1) return {
445
- prev: null,
446
- next: null
447
- };
448
- return {
449
- prev: index > 0 ? items[index - 1] ?? null : null,
450
- next: index < items.length - 1 ? items[index + 1] ?? null : null
451
- };
452
- }
453
463
  async persistMeta(slug, item, opts = {}) {
454
464
  let meta = buildCachedItemMeta(item, this.ctx.source);
455
465
  if (this.ctx.hooks.beforeCacheMeta) meta = await this.ctx.hooks.beforeCacheMeta(meta);
@@ -458,7 +468,7 @@ var CollectionClientImpl = class {
458
468
  else await save;
459
469
  return meta;
460
470
  }
461
- async invalidateContent(slug) {
471
+ async invalidateContentEntry(slug) {
462
472
  await this.ctx.docCache.invalidate({
463
473
  collection: this.ctx.collection,
464
474
  slug,
@@ -478,16 +488,31 @@ var CollectionClientImpl = class {
478
488
  this.ctx.hooks.onContentRevalidated?.(slug, fresh);
479
489
  return fresh;
480
490
  }
481
- /** メタ既知の状態で本文だけバックグラウンド再生成する。エラーは握りつぶす。 */
491
+ /** メタ既知の状態で本文だけバックグラウンド再生成する。エラーは onSwrError フックに通知する。 */
482
492
  async rebuildContentBg(slug, item) {
483
493
  try {
484
494
  const fresh = await buildCachedItemContent(item, this.ctx.render);
485
495
  await this.ctx.docCache.setContent(this.ctx.collection, slug, fresh);
486
496
  this.ctx.hooks.onContentRevalidated?.(slug, fresh);
487
497
  } catch (err) {
498
+ const cmsErr = isCMSError(err) ? err : new CMSError({
499
+ code: "swr/content_rebuild_failed",
500
+ message: "SWR background content rebuild failed.",
501
+ cause: err,
502
+ context: {
503
+ operation: "swr.rebuildContentBg",
504
+ collection: this.ctx.collection,
505
+ slug
506
+ }
507
+ });
508
+ this.ctx.hooks.onSwrError?.(cmsErr, {
509
+ phase: "item-content",
510
+ slug
511
+ });
488
512
  this.ctx.logger?.warn?.("本文のバックグラウンド再生成に失敗", {
489
513
  slug,
490
514
  collection: this.ctx.collection,
515
+ code: cmsErr.code,
491
516
  error: err instanceof Error ? err.message : String(err)
492
517
  });
493
518
  }
@@ -500,11 +525,11 @@ var CollectionClientImpl = class {
500
525
  if (!payloadPromise) payloadPromise = this.loadOrBuildContent(slug, item);
501
526
  return payloadPromise;
502
527
  };
503
- const render = async (opts) => {
504
- const payload = await loadPayload();
505
- return opts?.format === "markdown" ? payload.markdown : payload.html;
506
- };
507
- return Object.assign(Object.create(null), item, { render });
528
+ return Object.assign(Object.create(null), item, {
529
+ html: async () => (await loadPayload()).html,
530
+ markdown: async () => (await loadPayload()).markdown,
531
+ blocks: async () => (await loadPayload()).blocks
532
+ });
508
533
  }
509
534
  async fetchList() {
510
535
  const cached = await this.ctx.docCache.getList(this.ctx.collection);
@@ -551,13 +576,13 @@ var CollectionClientImpl = class {
551
576
  }
552
577
  async checkAndUpdateItemBg(slug, cached) {
553
578
  try {
554
- const item = await this.findRaw(slug);
579
+ const item = await this.fetchRaw(slug);
555
580
  if (!item) return;
556
581
  if (this.ctx.source.getLastModified(item) !== cached.notionUpdatedAt) {
557
582
  const meta = await this.persistMeta(slug, item);
558
- await this.invalidateContent(slug);
583
+ await this.invalidateContentEntry(slug);
559
584
  this.ctx.logger?.debug?.("SWR: 差分を検出、メタを差し替え", {
560
- operation: "get:bg",
585
+ operation: "find:bg",
561
586
  slug,
562
587
  collection: this.ctx.collection,
563
588
  notionUpdatedAt: cached.notionUpdatedAt
@@ -570,15 +595,30 @@ var CollectionClientImpl = class {
570
595
  cachedAt: Date.now()
571
596
  });
572
597
  this.ctx.logger?.debug?.("SWR: 差分なし、TTL をリセット", {
573
- operation: "get:bg",
598
+ operation: "find:bg",
574
599
  slug,
575
600
  collection: this.ctx.collection
576
601
  });
577
602
  }
578
603
  } catch (err) {
604
+ const cmsErr = isCMSError(err) ? err : new CMSError({
605
+ code: "swr/item_check_failed",
606
+ message: "SWR background item check failed.",
607
+ cause: err,
608
+ context: {
609
+ operation: "swr.checkAndUpdateItemBg",
610
+ collection: this.ctx.collection,
611
+ slug
612
+ }
613
+ });
614
+ this.ctx.hooks.onSwrError?.(cmsErr, {
615
+ phase: "item-meta",
616
+ slug
617
+ });
579
618
  this.ctx.logger?.warn?.("SWR: アイテムのバックグラウンド差分チェックに失敗", {
580
619
  slug,
581
620
  collection: this.ctx.collection,
621
+ code: cmsErr.code,
582
622
  error: err instanceof Error ? err.message : String(err)
583
623
  });
584
624
  }
@@ -608,14 +648,25 @@ var CollectionClientImpl = class {
608
648
  });
609
649
  }
610
650
  } catch (err) {
651
+ const cmsErr = isCMSError(err) ? err : new CMSError({
652
+ code: "swr/list_check_failed",
653
+ message: "SWR background list check failed.",
654
+ cause: err,
655
+ context: {
656
+ operation: "swr.checkAndUpdateListBg",
657
+ collection: this.ctx.collection
658
+ }
659
+ });
660
+ this.ctx.hooks.onSwrError?.(cmsErr, { phase: "list" });
611
661
  this.ctx.logger?.warn?.("SWR: リストのバックグラウンド差分チェックに失敗", {
612
662
  collection: this.ctx.collection,
663
+ code: cmsErr.code,
613
664
  error: err instanceof Error ? err.message : String(err)
614
665
  });
615
666
  }
616
667
  }
617
- fetchListRaw() {
618
- return withRetry(() => this.ctx.source.list({ publishedStatuses: this.ctx.publishedStatuses.length > 0 ? this.ctx.publishedStatuses : void 0 }), {
668
+ async fetchListRaw() {
669
+ return (await withRetry(() => this.ctx.source.list({ publishedStatuses: this.ctx.publishedStatuses.length > 0 ? this.ctx.publishedStatuses : void 0 }), {
619
670
  ...this.ctx.retryConfig,
620
671
  onRetry: (attempt, status) => {
621
672
  this.ctx.logger?.warn?.("list() リトライ中", {
@@ -623,13 +674,17 @@ var CollectionClientImpl = class {
623
674
  status
624
675
  });
625
676
  }
677
+ })).filter((item) => {
678
+ if (item.isArchived || item.isInTrash) return false;
679
+ if (this.ctx.accessibleStatuses.length > 0 && (!item.status || !this.ctx.accessibleStatuses.includes(item.status))) return false;
680
+ return true;
626
681
  });
627
682
  }
628
- async findRaw(slug) {
683
+ async fetchRaw(slug) {
629
684
  const retryOpts = {
630
685
  ...this.ctx.retryConfig,
631
686
  onRetry: (attempt, status) => {
632
- this.ctx.logger?.warn?.("get() リトライ中", {
687
+ this.ctx.logger?.warn?.("find() リトライ中", {
633
688
  attempt,
634
689
  status,
635
690
  slug
@@ -642,15 +697,26 @@ var CollectionClientImpl = class {
642
697
  if (notionPropName && findByProp) item = await withRetry(() => findByProp(notionPropName, slug), retryOpts);
643
698
  else item = (await withRetry(() => this.ctx.source.list(), retryOpts)).find((i) => i.slug === slug) ?? null;
644
699
  if (!item) return null;
700
+ if (item.isArchived || item.isInTrash) return null;
645
701
  if (this.ctx.accessibleStatuses.length > 0 && (!item.status || !this.ctx.accessibleStatuses.includes(item.status))) return null;
646
702
  return item;
647
703
  }
648
704
  };
705
+ function matchesWhere(item, where) {
706
+ for (const key of Object.keys(where)) {
707
+ const expected = where[key];
708
+ const actual = item[key];
709
+ if (Array.isArray(expected)) {
710
+ if (!expected.includes(actual)) return false;
711
+ } else if (actual !== expected) return false;
712
+ }
713
+ return true;
714
+ }
649
715
  function applyListOptions(items, opts) {
650
- if (!opts) return items;
716
+ if (!opts) return sortByPublishedAtDesc(items);
651
717
  let result = items;
652
- if (opts.status) {
653
- const allow = new Set(Array.isArray(opts.status) ? opts.status : [opts.status]);
718
+ if (opts.statuses) {
719
+ const allow = new Set(Array.isArray(opts.statuses) ? opts.statuses : [opts.statuses]);
654
720
  result = result.filter((it) => it.status != null && allow.has(it.status));
655
721
  }
656
722
  if (opts.tag) {
@@ -662,24 +728,46 @@ function applyListOptions(items, opts) {
662
728
  }
663
729
  if (opts.where) {
664
730
  const where = opts.where;
665
- result = result.filter((it) => Object.entries(where).every(([key, value]) => it[key] === value));
731
+ result = result.filter((it) => matchesWhere(it, where));
666
732
  }
733
+ if (opts.filter) result = result.filter(opts.filter);
667
734
  if (opts.sort) result = [...result].sort(makeComparator(opts.sort));
735
+ else result = sortByPublishedAtDesc(result);
668
736
  const skip = opts.skip ?? 0;
669
737
  const limit = opts.limit;
670
738
  if (skip > 0 || limit !== void 0) result = result.slice(skip, limit !== void 0 ? skip + limit : void 0);
671
739
  return result;
672
740
  }
741
+ /** publishedAt 降順、未設定の場合は lastEditedTime 降順でソートする。 */
742
+ function sortByPublishedAtDesc(items) {
743
+ return [...items].sort((a, b) => {
744
+ const av = a.publishedAt ?? a.lastEditedTime;
745
+ const bv = b.publishedAt ?? b.lastEditedTime;
746
+ if (av === bv) return 0;
747
+ return av > bv ? -1 : 1;
748
+ });
749
+ }
673
750
  function makeComparator(sort) {
751
+ if (sort.compare) return sort.compare;
674
752
  const by = sort.by;
675
753
  const dir = sort.dir === "asc" ? 1 : -1;
676
754
  return (a, b) => {
677
755
  const av = a[by];
678
756
  const bv = b[by];
679
757
  if (av === bv) return 0;
680
- if (av === void 0) return 1;
681
- if (bv === void 0) return -1;
682
- return av > bv ? dir : -dir;
758
+ if (av === void 0 || av === null) return 1;
759
+ if (bv === void 0 || bv === null) return -1;
760
+ if (typeof av === "string" && typeof bv === "string") return av > bv ? dir : -dir;
761
+ if (typeof av === "number" && typeof bv === "number") return av > bv ? dir : -dir;
762
+ throw new CMSError({
763
+ code: "core/sort_unsupported_type",
764
+ message: `"${String(by)}" フィールドの型 "${typeof av}" はソート非対応です。compare 関数を指定してください。`,
765
+ context: {
766
+ operation: "makeComparator",
767
+ field: String(by),
768
+ type: typeof av
769
+ }
770
+ });
683
771
  };
684
772
  }
685
773
  //#endregion
@@ -690,12 +778,23 @@ const DEFAULT_OPTS = {
690
778
  revalidatePath: "/revalidate"
691
779
  };
692
780
  /**
781
+ * CMSError のコードから HTTP ステータスコードを返す。
782
+ * 既知の webhook エラーコードのみ対応し、それ以外は null を返す。
783
+ */
784
+ function webhookErrorStatus(code) {
785
+ if (code === "webhook/signature_invalid") return 401;
786
+ if (code === "webhook/not_implemented") return 501;
787
+ if (code === "webhook/unknown_collection") return 404;
788
+ if (code === "webhook/payload_invalid") return 400;
789
+ return null;
790
+ }
791
+ /**
693
792
  * Web Standard な Request → Response ルーター。
694
793
  * Next.js / React Router / Hono / Cloudflare Workers いずれでも使える。
695
794
  *
696
795
  * ルート:
697
- * - GET `{basePath}/images/:hash` — 画像プロキシ
698
- * - POST `{basePath}/revalidate` — Webhook 受信 + $revalidate()
796
+ * - GET `{basePath}/images/:hash` — 画像プロキシ
797
+ * - POST `{basePath}/revalidate/:collection` — Webhook 受信 + $revalidate()
699
798
  */
700
799
  function createHandler(adapter, opts = {}) {
701
800
  const basePath = trimTrailingSlash(opts.basePath ?? DEFAULT_OPTS.basePath);
@@ -715,23 +814,38 @@ function createHandler(adapter, opts = {}) {
715
814
  headers.set("cache-control", "public, max-age=31536000, immutable");
716
815
  return new Response(object.data, { headers });
717
816
  }
718
- if (req.method === "POST" && rel === revalidatePath) {
719
- const scope = await adapter.parseWebhook(req, opts.webhookSecret);
720
- if (!scope) return new Response(JSON.stringify({
817
+ if (req.method === "POST" && rel.startsWith(`${revalidatePath}/`)) {
818
+ const collection = rel.slice(revalidatePath.length + 1);
819
+ if (!collection || collection.includes("/")) return new Response(JSON.stringify({
721
820
  ok: false,
722
- reason: "invalid"
821
+ reason: "collection required"
723
822
  }), {
724
823
  status: 400,
725
824
  headers: { "content-type": "application/json" }
726
825
  });
727
- await adapter.revalidate(scope);
728
- return new Response(JSON.stringify({
729
- ok: true,
730
- scope
731
- }), {
732
- status: 200,
733
- headers: { "content-type": "application/json" }
734
- });
826
+ try {
827
+ const scope = await adapter.parseWebhookFor(collection, req, opts.webhookSecret);
828
+ await adapter.revalidate(scope);
829
+ return new Response(JSON.stringify({
830
+ ok: true,
831
+ scope
832
+ }), {
833
+ status: 200,
834
+ headers: { "content-type": "application/json" }
835
+ });
836
+ } catch (err) {
837
+ if (isCMSError(err)) {
838
+ const status = webhookErrorStatus(err.code);
839
+ if (status !== null) return new Response(JSON.stringify({
840
+ ok: false,
841
+ code: err.code
842
+ }), {
843
+ status,
844
+ headers: { "content-type": "application/json" }
845
+ });
846
+ }
847
+ throw err;
848
+ }
735
849
  }
736
850
  return new Response("Not Found", { status: 404 });
737
851
  };
@@ -745,12 +859,11 @@ const DEFAULT_IMAGE_PROXY_BASE = "/api/images";
745
859
  /**
746
860
  * `cache` オプションから document / image オペレーションを解決する。
747
861
  *
748
- * - 配列で渡された場合は各 adapter の `handles` を見て先勝ち (最初に見つかったもの) で振り分ける
749
- * - 単体で渡された場合は `handles` の領域だけ反映、片側は noop
862
+ * - adapter の `handles` を見て先勝ち (最初に見つかったもの) で振り分ける
750
863
  * - 未指定なら両方 noop
751
864
  */
752
865
  function resolveCache(cache) {
753
- const adapters = cache === void 0 ? [] : Array.isArray(cache) ? cache : [cache];
866
+ const adapters = cache ?? [];
754
867
  let doc = noopDocOps;
755
868
  let docName = "noop-document";
756
869
  let img = noopImgOps;
@@ -812,7 +925,8 @@ function applyLogLevel(logger, minLevel) {
812
925
  * publishedStatuses: ["公開済み"],
813
926
  * }
814
927
  * },
815
- * cache: memoryCache({ ttlMs: 5 * 60_000 }),
928
+ * cache: [memoryCache()],
929
+ * swr: { ttlMs: 5 * 60_000 },
816
930
  * });
817
931
  */
818
932
  function createCMS(opts) {
@@ -840,7 +954,7 @@ function createCMS(opts) {
840
954
  });
841
955
  }
842
956
  const cacheRes = resolveCache(opts.cache);
843
- const ttlMs = opts.ttlMs;
957
+ const ttlMs = opts.swr?.ttlMs;
844
958
  const imageProxyBase = opts.imageProxyBase ?? DEFAULT_IMAGE_PROXY_BASE;
845
959
  const contentConfig = opts.content;
846
960
  const rendererFn = opts.renderer;
@@ -853,10 +967,10 @@ function createCMS(opts) {
853
967
  ...DEFAULT_RETRY_CONFIG,
854
968
  ...opts.rateLimiter ?? {}
855
969
  };
856
- const collectionNames = Object.keys(opts.collections);
970
+ const collectionNames = [];
857
971
  const collections = {};
858
- for (const name of collectionNames) {
859
- const def = opts.collections[name];
972
+ for (const [name, def] of Object.entries(opts.collections)) {
973
+ collectionNames.push(name);
860
974
  const source = def.source;
861
975
  const colHooks = def.hooks;
862
976
  const collectionHooks = colHooks ? mergeHooks([{
@@ -892,43 +1006,42 @@ function createCMS(opts) {
892
1006
  });
893
1007
  }
894
1008
  const globalOps = {
895
- $collections: collectionNames,
896
- async $invalidate(scope) {
1009
+ collections: collectionNames,
1010
+ async invalidate(scope) {
897
1011
  logger?.debug?.("グローバルキャッシュを無効化", {
898
- operation: "$invalidate",
1012
+ operation: "invalidate",
899
1013
  cacheAdapter: cacheRes.docName
900
1014
  });
901
1015
  await cacheRes.doc.invalidate(scope ?? "all");
902
1016
  },
903
- $handler(handlerOpts) {
1017
+ handler(handlerOpts) {
904
1018
  return createHandler({
905
1019
  imageCache: cacheRes.img,
906
- parseWebhook: async (req, webhookSecret) => {
907
- for (const name of collectionNames) {
908
- const ds = opts.collections[name].source;
909
- if (ds.parseWebhook) try {
910
- return await ds.parseWebhook(req.clone(), { secret: webhookSecret });
911
- } catch (err) {
912
- logger?.warn?.("parseWebhook 失敗", {
913
- collection: name,
914
- error: err instanceof Error ? err.message : String(err)
915
- });
1020
+ async parseWebhookFor(collection, req, webhookSecret) {
1021
+ const def = opts.collections[collection];
1022
+ if (!def) throw new CMSError({
1023
+ code: "webhook/unknown_collection",
1024
+ message: `Unknown collection: ${collection}`,
1025
+ context: {
1026
+ operation: "parseWebhookFor",
1027
+ collection
916
1028
  }
917
- }
918
- try {
919
- const body = await req.json();
920
- if (body.slug && body.collection) return {
921
- collection: body.collection,
922
- slug: body.slug
923
- };
924
- if (body.collection) return { collection: body.collection };
925
- } catch {}
926
- return null;
1029
+ });
1030
+ const ds = def.source;
1031
+ if (!ds.parseWebhook) throw new CMSError({
1032
+ code: "webhook/not_implemented",
1033
+ message: `Collection "${collection}" does not support webhooks.`,
1034
+ context: {
1035
+ operation: "parseWebhookFor",
1036
+ collection
1037
+ }
1038
+ });
1039
+ return ds.parseWebhook(req, { secret: webhookSecret });
927
1040
  },
928
- revalidate: (scope) => globalOps.$invalidate(scope)
1041
+ revalidate: (scope) => globalOps.invalidate(scope)
929
1042
  }, handlerOpts);
930
1043
  },
931
- $getCachedImage(hash) {
1044
+ getCachedImage(hash) {
932
1045
  return cacheRes.img.get(hash);
933
1046
  }
934
1047
  };
@@ -940,6 +1053,6 @@ function definePlugin(plugin) {
940
1053
  return plugin;
941
1054
  }
942
1055
  //#endregion
943
- export { CMSError, CollectionClientImpl, DEFAULT_RETRY_CONFIG, collectionKey, createCMS, createHandler, definePlugin, isCMSError, isCMSErrorInNamespace, isStale, memoryCache, mergeHooks, mergeLoggers, noopDocOps, noopImgOps, sha256Hex, withRetry };
1056
+ export { CMSError, CollectionClientImpl, DEFAULT_RETRY_CONFIG, collectionKey, createCMS, createHandler, definePlugin, isCMSError, isCMSErrorInNamespace, isStale, matchCMSError, memoryCache, mergeHooks, mergeLoggers, noopDocOps, noopImgOps, sha256Hex, withRetry };
944
1057
 
945
1058
  //# sourceMappingURL=index.mjs.map