@notion-headless-cms/core 0.3.7 → 0.3.9

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,4 @@
1
- import { memoryDocumentCache, memoryImageCache } from "./cache/memory.mjs";
2
- import { noopDocumentCache, noopImageCache } from "./cache/noop.mjs";
1
+ import { memoryCache } from "./cache/memory.mjs";
3
2
  import { CMSError, isCMSError, isCMSErrorInNamespace } from "./errors.mjs";
4
3
  import { mergeHooks, mergeLoggers } from "./hooks.mjs";
5
4
  //#region src/cache.ts
@@ -18,6 +17,47 @@ function isStale(cachedAt, ttlMs) {
18
17
  return Date.now() - cachedAt > ttlMs;
19
18
  }
20
19
  //#endregion
20
+ //#region src/cache/noop.ts
21
+ /** 何もキャッシュしないドキュメントオペレーション。常に null を返す。 */
22
+ const noopDoc = {
23
+ getList(_collection) {
24
+ return Promise.resolve(null);
25
+ },
26
+ setList(_collection, _data) {
27
+ return Promise.resolve();
28
+ },
29
+ getMeta(_collection, _slug) {
30
+ return Promise.resolve(null);
31
+ },
32
+ setMeta(_collection, _slug, _data) {
33
+ return Promise.resolve();
34
+ },
35
+ getContent(_collection, _slug) {
36
+ return Promise.resolve(null);
37
+ },
38
+ setContent(_collection, _slug, _data) {
39
+ return Promise.resolve();
40
+ },
41
+ invalidate() {
42
+ return Promise.resolve();
43
+ }
44
+ };
45
+ /** 何もキャッシュしない画像オペレーション。 */
46
+ const noopImg = {
47
+ get(_hash) {
48
+ return Promise.resolve(null);
49
+ },
50
+ set() {
51
+ return Promise.resolve();
52
+ }
53
+ };
54
+ /**
55
+ * 何もキャッシュしないアダプタ。`createCMS({ cache })` 未指定時の内部デフォルト。
56
+ * テストでも使える。
57
+ */
58
+ const noopDocOps = noopDoc;
59
+ const noopImgOps = noopImg;
60
+ //#endregion
21
61
  //#region src/image.ts
22
62
  /** レスポンスヘッダまたはURLの拡張子からContent-Typeを推測する。 */
23
63
  function inferContentType(url, responseContentType) {
@@ -28,23 +68,47 @@ function inferContentType(url, responseContentType) {
28
68
  return "image/jpeg";
29
69
  }
30
70
  /**
31
- * Notion画像URLをfetchしてImageCacheAdapterにキャッシュし、プロキシURL を返す。
71
+ * URL → SHA-256 hash のメモ化マップ。
72
+ * Notion の画像 URL は同じ画像でも署名が時刻ごとに変わるが、
73
+ * 1 リクエスト内では同一 URL が複数回現れることが多い (重複ハッシュ計算を回避)。
74
+ *
75
+ * メモリリーク防止に最大エントリ数を設けており、超過時は最古から削除する LRU。
76
+ */
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;
93
+ }
94
+ /**
95
+ * Notion画像URLをfetchして ImageCacheOps にキャッシュし、プロキシURL を返す。
32
96
  * 既存キャッシュがあれば再fetchしない。
33
97
  */
34
- async function fetchAndCacheImage(cache, notionUrl, imageProxyBase, logger) {
35
- const hash = await sha256Hex(notionUrl);
98
+ async function fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, logger) {
99
+ const hash = await memoSha256(notionUrl);
36
100
  const proxyUrl = `${imageProxyBase}/${hash}`;
37
101
  if (await cache.get(hash)) {
38
102
  logger?.debug?.("画像キャッシュヒット", {
39
103
  operation: "fetchAndCacheImage",
40
- cacheAdapter: cache.name,
104
+ cacheAdapter: cacheName,
41
105
  imageHash: hash
42
106
  });
43
107
  return proxyUrl;
44
108
  }
45
109
  logger?.debug?.("画像キャッシュミス、Notion からフェッチ", {
46
110
  operation: "fetchAndCacheImage",
47
- cacheAdapter: cache.name,
111
+ cacheAdapter: cacheName,
48
112
  imageHash: hash
49
113
  });
50
114
  try {
@@ -63,7 +127,7 @@ async function fetchAndCacheImage(cache, notionUrl, imageProxyBase, logger) {
63
127
  await cache.set(hash, data, contentType);
64
128
  logger?.debug?.("画像をキャッシュに保存", {
65
129
  operation: "fetchAndCacheImage",
66
- cacheAdapter: cache.name,
130
+ cacheAdapter: cacheName,
67
131
  imageHash: hash
68
132
  });
69
133
  } catch (err) {
@@ -81,23 +145,30 @@ async function fetchAndCacheImage(cache, notionUrl, imageProxyBase, logger) {
81
145
  return proxyUrl;
82
146
  }
83
147
  /**
84
- * ImageCacheAdapter と imageProxyBase から cacheImage 関数を構築するファクトリ。
85
- *
86
- * 返り値の関数は Notion 画像 URL を受け取り、SHA-256 ハッシュをキャッシュキーとして
87
- * {@link ImageCacheAdapter} に保存後、フロントエンドへの配信用プロキシ URL を返す。
88
- * Content-Type はレスポンスヘッダまたは URL の拡張子から推測する。
89
- * タイムアウトは 10 秒固定。
148
+ * `ImageCacheOps``imageProxyBase` から `cacheImage` 関数を構築する。
149
+ * 返り値は Notion 画像 URL を受け取り、SHA-256 ハッシュをキャッシュキーとして
150
+ * {@link ImageCacheOps} に保存後、プロキシ URL を返す。
90
151
  */
91
- function buildCacheImageFn(cache, imageProxyBase, logger) {
92
- return (notionUrl) => fetchAndCacheImage(cache, notionUrl, imageProxyBase, logger);
152
+ function buildCacheImageFn(cache, cacheName, imageProxyBase, logger) {
153
+ return (notionUrl) => fetchAndCacheImage(cache, cacheName, notionUrl, imageProxyBase, logger);
93
154
  }
94
155
  //#endregion
95
156
  //#region src/rendering.ts
96
157
  /**
97
- * コンテンツアイテムをソースから Markdown ロード blocks 生成 → HTML レンダリング
98
- * → フック適用まで実行し、キャッシュ保存用の `CachedItem` を返す。
158
+ * メタデータキャッシュエントリを生成する。Notion API renderer も呼ばない軽量関数。
159
+ */
160
+ function buildCachedItemMeta(item, source) {
161
+ return {
162
+ item,
163
+ notionUpdatedAt: source.getLastModified(item),
164
+ cachedAt: Date.now()
165
+ };
166
+ }
167
+ /**
168
+ * アイテム本文を Markdown ロード → blocks 生成 → HTML レンダリング → フック適用まで
169
+ * 実行し、本文キャッシュ用の `CachedItemContent` を返す。
99
170
  */
100
- async function buildCachedItem(item, ctx) {
171
+ async function buildCachedItemContent(item, ctx) {
101
172
  const start = Date.now();
102
173
  ctx.logger?.info?.("コンテンツのレンダリング開始", {
103
174
  slug: item.slug,
@@ -114,7 +185,7 @@ async function buildCachedItem(item, ctx) {
114
185
  message: "Failed to load markdown from source.",
115
186
  cause: err,
116
187
  context: {
117
- operation: "buildCachedItem:loadMarkdown",
188
+ operation: "buildCachedItemContent:loadMarkdown",
118
189
  pageId: item.id,
119
190
  slug: item.slug
120
191
  }
@@ -130,7 +201,7 @@ async function buildCachedItem(item, ctx) {
130
201
  });
131
202
  blocks = [];
132
203
  }
133
- const cacheImage = ctx.hasImageCache ? buildCacheImageFn(ctx.imgCache, ctx.imageProxyBase, ctx.logger) : void 0;
204
+ const cacheImage = ctx.hasImageCache ? buildCacheImageFn(ctx.imgCache, ctx.imgCacheName, ctx.imageProxyBase, ctx.logger) : void 0;
134
205
  let html;
135
206
  const rendererFn = ctx.rendererFn ?? await loadDefaultRenderer();
136
207
  try {
@@ -147,7 +218,7 @@ async function buildCachedItem(item, ctx) {
147
218
  message: "Failed to render markdown.",
148
219
  cause: err,
149
220
  context: {
150
- operation: "buildCachedItem:renderMarkdown",
221
+ operation: "buildCachedItemContent:renderMarkdown",
151
222
  pageId: item.id,
152
223
  slug: item.slug
153
224
  }
@@ -158,11 +229,10 @@ async function buildCachedItem(item, ctx) {
158
229
  html,
159
230
  blocks,
160
231
  markdown,
161
- item,
162
232
  notionUpdatedAt: ctx.source.getLastModified(item),
163
233
  cachedAt: Date.now()
164
234
  };
165
- if (ctx.hooks.beforeCache) result = await ctx.hooks.beforeCache(result);
235
+ if (ctx.hooks.beforeCacheContent) result = await ctx.hooks.beforeCacheContent(result, item);
166
236
  const durationMs = Date.now() - start;
167
237
  ctx.logger?.info?.("コンテンツのレンダリング完了", {
168
238
  slug: item.slug,
@@ -230,132 +300,101 @@ async function withRetry(fn, config) {
230
300
  /**
231
301
  * コレクション別キャッシュキーを生成する。
232
302
  * item: `{collection}:{slug}` / list: `{collection}`
303
+ *
304
+ * (Cache adapter 内部のキー戦略はアダプタごとに異なるが、
305
+ * 表示や再計算用に core 側でも公開ヘルパーを提供する)
233
306
  */
234
307
  function collectionKey(collection, slug) {
235
308
  return slug ? `${collection}:${slug}` : collection;
236
309
  }
237
310
  /** CollectionClient の実装。ユーザーは `createCMS` 経由でインスタンスを受け取る。 */
238
311
  var CollectionClientImpl = class {
312
+ cache;
239
313
  constructor(ctx) {
240
314
  this.ctx = ctx;
315
+ this.cache = {
316
+ invalidate: (slug) => this.invalidateImpl(slug),
317
+ warm: (opts) => this.warmImpl(opts),
318
+ adjacent: (slug, opts) => this.adjacentImpl(slug, opts)
319
+ };
241
320
  }
242
- async getItem(slug) {
243
- const cached = await this.ctx.docCache.getItem(slug);
244
- if (cached) {
245
- if (this.ctx.ttlMs !== void 0 && isStale(cached.cachedAt, this.ctx.ttlMs)) {
321
+ async get(slug, opts = {}) {
322
+ if (opts.fresh) {
323
+ this.ctx.hooks.onCacheMiss?.(slug);
324
+ const item = await this.findRaw(slug);
325
+ if (!item) return null;
326
+ const meta = await this.persistMeta(slug, item);
327
+ await this.invalidateContent(slug);
328
+ return this.attachLazyContent(meta);
329
+ }
330
+ const cachedMeta = await this.ctx.docCache.getMeta(this.ctx.collection, slug);
331
+ if (cachedMeta) {
332
+ if (this.ctx.ttlMs !== void 0 && isStale(cachedMeta.cachedAt, this.ctx.ttlMs)) {
246
333
  this.ctx.logger?.debug?.("キャッシュ期限切れ(TTL)、フェッチ", {
247
- operation: "getItem",
334
+ operation: "get",
248
335
  slug,
249
336
  collection: this.ctx.collection,
250
- cacheAdapter: this.ctx.docCache.name
337
+ cacheAdapter: this.ctx.docCacheName
251
338
  });
252
339
  this.ctx.hooks.onCacheMiss?.(slug);
253
340
  const item = await this.findRaw(slug);
254
341
  if (!item) return null;
255
- const entry = await buildCachedItem(item, this.ctx.render);
256
- await this.ctx.docCache.setItem(slug, entry);
257
- return this.attachContent(entry.item, entry);
342
+ const meta = await this.persistMeta(slug, item);
343
+ await this.invalidateContent(slug);
344
+ return this.attachLazyContent(meta);
258
345
  }
259
- const bg = this.checkAndUpdateItemBg(slug, cached);
346
+ const bg = this.checkAndUpdateItemBg(slug, cachedMeta);
260
347
  if (this.ctx.waitUntil) this.ctx.waitUntil(bg);
261
348
  this.ctx.logger?.debug?.("キャッシュヒット", {
262
- operation: "getItem",
349
+ operation: "get",
263
350
  slug,
264
351
  collection: this.ctx.collection,
265
- cacheAdapter: this.ctx.docCache.name,
266
- cachedAt: cached.cachedAt
352
+ cacheAdapter: this.ctx.docCacheName,
353
+ cachedAt: cachedMeta.cachedAt
267
354
  });
268
- this.ctx.hooks.onCacheHit?.(slug, cached);
269
- return this.attachContent(cached.item, cached);
355
+ this.ctx.hooks.onCacheHit?.(slug, cachedMeta);
356
+ return this.attachLazyContent(cachedMeta);
270
357
  }
271
358
  this.ctx.logger?.debug?.("キャッシュミス、フェッチ", {
272
- operation: "getItem",
359
+ operation: "get",
273
360
  slug,
274
361
  collection: this.ctx.collection,
275
- cacheAdapter: this.ctx.docCache.name
362
+ cacheAdapter: this.ctx.docCacheName
276
363
  });
277
364
  this.ctx.hooks.onCacheMiss?.(slug);
278
365
  const item = await this.findRaw(slug);
279
- if (!item) {
280
- this.ctx.logger?.debug?.("アイテムが見つかりません", {
281
- operation: "getItem",
282
- slug,
283
- collection: this.ctx.collection
284
- });
285
- return null;
286
- }
287
- const entry = await buildCachedItem(item, this.ctx.render);
288
- const save = this.ctx.docCache.setItem(slug, entry);
289
- if (this.ctx.waitUntil) this.ctx.waitUntil(save);
290
- else await save;
291
- return this.attachContent(entry.item, entry);
366
+ if (!item) return null;
367
+ const meta = await this.persistMeta(slug, item, { background: true });
368
+ return this.attachLazyContent(meta);
292
369
  }
293
- async getList(opts) {
294
- const items = applyGetListOptions(await this.fetchList(), opts);
295
- return {
296
- items,
297
- version: this.ctx.source.getListVersion(items)
298
- };
370
+ async list(opts) {
371
+ return applyListOptions(await this.fetchList(), opts);
299
372
  }
300
- async getStaticParams() {
373
+ async params() {
301
374
  return (await this.fetchList()).map((item) => ({ slug: item.slug }));
302
375
  }
303
- async getStaticPaths() {
304
- return (await this.fetchList()).map((item) => item.slug);
305
- }
306
- async adjacent(slug, opts) {
307
- const items = applyGetListOptions(await this.fetchList(), { sort: opts?.sort });
308
- const index = items.findIndex((it) => it.slug === slug);
309
- if (index === -1) return {
310
- prev: null,
311
- next: null
312
- };
313
- return {
314
- prev: index > 0 ? items[index - 1] ?? null : null,
315
- next: index < items.length - 1 ? items[index + 1] ?? null : null
316
- };
317
- }
318
- async revalidate(slug) {
319
- this.ctx.logger?.debug?.("アイテムキャッシュを無効化", {
320
- operation: "revalidate",
321
- collection: this.ctx.collection,
322
- cacheAdapter: this.ctx.docCache.name,
323
- slug
324
- });
325
- if (!this.ctx.docCache.invalidate) return;
326
- await this.ctx.docCache.invalidate({
327
- collection: this.ctx.collection,
328
- slug
329
- });
330
- }
331
- async revalidateAll() {
376
+ async invalidateImpl(slug) {
377
+ if (slug !== void 0) {
378
+ this.ctx.logger?.debug?.("アイテムキャッシュを無効化", {
379
+ operation: "cache.invalidate",
380
+ collection: this.ctx.collection,
381
+ cacheAdapter: this.ctx.docCacheName,
382
+ slug
383
+ });
384
+ await this.ctx.docCache.invalidate({
385
+ collection: this.ctx.collection,
386
+ slug
387
+ });
388
+ return;
389
+ }
332
390
  this.ctx.logger?.debug?.("コレクション全体のキャッシュを無効化", {
333
- operation: "revalidateAll",
391
+ operation: "cache.invalidate",
334
392
  collection: this.ctx.collection,
335
- cacheAdapter: this.ctx.docCache.name
393
+ cacheAdapter: this.ctx.docCacheName
336
394
  });
337
- if (!this.ctx.docCache.invalidate) return;
338
395
  await this.ctx.docCache.invalidate({ collection: this.ctx.collection });
339
396
  }
340
- async checkForUpdate({ slug, since }) {
341
- await this.revalidate(slug);
342
- const item = await this.getItem(slug);
343
- if (!item) return { changed: false };
344
- return item.updatedAt !== since ? {
345
- changed: true,
346
- item
347
- } : { changed: false };
348
- }
349
- async checkListForUpdate({ since, filter }) {
350
- await this.revalidateAll();
351
- const { items, version } = await this.getList(filter);
352
- return version !== since ? {
353
- changed: true,
354
- items,
355
- version
356
- } : { changed: false };
357
- }
358
- async prefetch(opts) {
397
+ async warmImpl(opts) {
359
398
  const items = await this.fetchListRaw();
360
399
  const concurrency = opts?.concurrency ?? this.ctx.maxConcurrent;
361
400
  let ok = 0;
@@ -364,12 +403,13 @@ var CollectionClientImpl = class {
364
403
  const chunk = items.slice(i, i + concurrency);
365
404
  await Promise.all(chunk.map(async (item) => {
366
405
  try {
367
- const rendered = await buildCachedItem(item, this.ctx.render);
368
- await this.ctx.docCache.setItem(item.slug, rendered);
406
+ await this.persistMeta(item.slug, item);
407
+ const content = await buildCachedItemContent(item, this.ctx.render);
408
+ await this.ctx.docCache.setContent(this.ctx.collection, item.slug, content);
369
409
  ok++;
370
410
  } catch (err) {
371
411
  failed++;
372
- this.ctx.logger?.warn?.("prefetch: アイテムの事前レンダリングに失敗", {
412
+ this.ctx.logger?.warn?.("warm: アイテムの事前レンダリングに失敗", {
373
413
  slug: item.slug,
374
414
  pageId: item.id,
375
415
  error: err instanceof Error ? err.message : String(err)
@@ -378,7 +418,7 @@ var CollectionClientImpl = class {
378
418
  }));
379
419
  opts?.onProgress?.(Math.min(i + concurrency, items.length), items.length);
380
420
  }
381
- await this.ctx.docCache.setList({
421
+ await this.ctx.docCache.setList(this.ctx.collection, {
382
422
  items,
383
423
  cachedAt: Date.now()
384
424
  });
@@ -387,45 +427,86 @@ var CollectionClientImpl = class {
387
427
  failed
388
428
  };
389
429
  }
390
- attachContent(item, cached) {
391
- const ctx = this.ctx;
392
- let blocksCache;
393
- let htmlCache = cached.html;
394
- let markdownCache;
395
- const content = {
396
- get blocks() {
397
- if (!blocksCache) blocksCache = [{
398
- type: "raw",
399
- html: cached.html
400
- }];
401
- return blocksCache;
402
- },
403
- async html() {
404
- if (htmlCache !== void 0) return htmlCache;
405
- htmlCache = cached.html;
406
- return htmlCache;
407
- },
408
- async markdown() {
409
- if (markdownCache !== void 0) return markdownCache;
410
- markdownCache = await ctx.source.loadMarkdown(item);
411
- return markdownCache;
412
- }
430
+ async adjacentImpl(slug, opts) {
431
+ const items = applyListOptions(await this.fetchList(), { sort: opts?.sort });
432
+ const index = items.findIndex((it) => it.slug === slug);
433
+ if (index === -1) return {
434
+ prev: null,
435
+ next: null
436
+ };
437
+ return {
438
+ prev: index > 0 ? items[index - 1] ?? null : null,
439
+ next: index < items.length - 1 ? items[index + 1] ?? null : null
440
+ };
441
+ }
442
+ async persistMeta(slug, item, opts = {}) {
443
+ let meta = buildCachedItemMeta(item, this.ctx.source);
444
+ if (this.ctx.hooks.beforeCacheMeta) meta = await this.ctx.hooks.beforeCacheMeta(meta);
445
+ const save = this.ctx.docCache.setMeta(this.ctx.collection, slug, meta);
446
+ if (opts.background && this.ctx.waitUntil) this.ctx.waitUntil(save);
447
+ else await save;
448
+ return meta;
449
+ }
450
+ async invalidateContent(slug) {
451
+ await this.ctx.docCache.invalidate({
452
+ collection: this.ctx.collection,
453
+ slug,
454
+ kind: "content"
455
+ });
456
+ }
457
+ /**
458
+ * 本文キャッシュをロードする。キャッシュが無いか、メタとの整合性が取れない場合は
459
+ * 再生成して書き戻す。
460
+ */
461
+ async loadOrBuildContent(slug, item) {
462
+ const expected = this.ctx.source.getLastModified(item);
463
+ const cached = await this.ctx.docCache.getContent(this.ctx.collection, slug);
464
+ if (cached && cached.notionUpdatedAt === expected) return cached;
465
+ const fresh = await buildCachedItemContent(item, this.ctx.render);
466
+ await this.ctx.docCache.setContent(this.ctx.collection, slug, fresh);
467
+ this.ctx.hooks.onContentRevalidated?.(slug, fresh);
468
+ return fresh;
469
+ }
470
+ /** メタ既知の状態で本文だけバックグラウンド再生成する。エラーは握りつぶす。 */
471
+ async rebuildContentBg(slug, item) {
472
+ try {
473
+ const fresh = await buildCachedItemContent(item, this.ctx.render);
474
+ await this.ctx.docCache.setContent(this.ctx.collection, slug, fresh);
475
+ this.ctx.hooks.onContentRevalidated?.(slug, fresh);
476
+ } catch (err) {
477
+ this.ctx.logger?.warn?.("本文のバックグラウンド再生成に失敗", {
478
+ slug,
479
+ collection: this.ctx.collection,
480
+ error: err instanceof Error ? err.message : String(err)
481
+ });
482
+ }
483
+ }
484
+ attachLazyContent(meta) {
485
+ const slug = meta.item.slug;
486
+ const item = meta.item;
487
+ let payloadPromise;
488
+ const loadPayload = () => {
489
+ if (!payloadPromise) payloadPromise = this.loadOrBuildContent(slug, item);
490
+ return payloadPromise;
491
+ };
492
+ const render = async (opts) => {
493
+ const payload = await loadPayload();
494
+ return opts?.format === "markdown" ? payload.markdown : payload.html;
413
495
  };
414
- if (cached.blocks) blocksCache = cached.blocks;
415
- return Object.assign(Object.create(null), item, { content });
496
+ return Object.assign(Object.create(null), item, { render });
416
497
  }
417
498
  async fetchList() {
418
- const cached = await this.ctx.docCache.getList();
499
+ const cached = await this.ctx.docCache.getList(this.ctx.collection);
419
500
  if (cached) {
420
501
  if (this.ctx.ttlMs !== void 0 && isStale(cached.cachedAt, this.ctx.ttlMs)) {
421
502
  this.ctx.logger?.debug?.("リストキャッシュ期限切れ(TTL)、フェッチ", {
422
- operation: "getList",
503
+ operation: "list",
423
504
  collection: this.ctx.collection,
424
- cacheAdapter: this.ctx.docCache.name
505
+ cacheAdapter: this.ctx.docCacheName
425
506
  });
426
507
  this.ctx.hooks.onListCacheMiss?.();
427
508
  const items = await this.fetchListRaw();
428
- await this.ctx.docCache.setList({
509
+ await this.ctx.docCache.setList(this.ctx.collection, {
429
510
  items,
430
511
  cachedAt: Date.now()
431
512
  });
@@ -434,22 +515,22 @@ var CollectionClientImpl = class {
434
515
  const bg = this.checkAndUpdateListBg(cached);
435
516
  if (this.ctx.waitUntil) this.ctx.waitUntil(bg);
436
517
  this.ctx.logger?.debug?.("リストキャッシュヒット", {
437
- operation: "getList",
518
+ operation: "list",
438
519
  collection: this.ctx.collection,
439
- cacheAdapter: this.ctx.docCache.name
520
+ cacheAdapter: this.ctx.docCacheName
440
521
  });
441
522
  this.ctx.hooks.onListCacheHit?.(cached);
442
523
  return cached.items;
443
524
  }
444
525
  this.ctx.logger?.debug?.("リストキャッシュミス、フェッチ", {
445
- operation: "getList",
526
+ operation: "list",
446
527
  collection: this.ctx.collection,
447
- cacheAdapter: this.ctx.docCache.name
528
+ cacheAdapter: this.ctx.docCacheName
448
529
  });
449
530
  this.ctx.hooks.onListCacheMiss?.();
450
531
  const items = await this.fetchListRaw();
451
532
  const cachedAt = Date.now();
452
- const save = this.ctx.docCache.setList({
533
+ const save = this.ctx.docCache.setList(this.ctx.collection, {
453
534
  items,
454
535
  cachedAt
455
536
  });
@@ -462,22 +543,23 @@ var CollectionClientImpl = class {
462
543
  const item = await this.findRaw(slug);
463
544
  if (!item) return;
464
545
  if (this.ctx.source.getLastModified(item) !== cached.notionUpdatedAt) {
465
- const entry = await buildCachedItem(item, this.ctx.render);
466
- await this.ctx.docCache.setItem(slug, entry);
467
- this.ctx.logger?.debug?.("SWR: 差分を検出、キャッシュを差し替え", {
468
- operation: "getItem:bg",
546
+ const meta = await this.persistMeta(slug, item);
547
+ await this.invalidateContent(slug);
548
+ this.ctx.logger?.debug?.("SWR: 差分を検出、メタを差し替え", {
549
+ operation: "get:bg",
469
550
  slug,
470
551
  collection: this.ctx.collection,
471
552
  notionUpdatedAt: cached.notionUpdatedAt
472
553
  });
473
- this.ctx.hooks.onCacheRevalidated?.(slug, entry);
554
+ this.ctx.hooks.onCacheRevalidated?.(slug, meta);
555
+ await this.rebuildContentBg(slug, item);
474
556
  } else if (this.ctx.ttlMs !== void 0) {
475
- await this.ctx.docCache.setItem(slug, {
557
+ await this.ctx.docCache.setMeta(this.ctx.collection, slug, {
476
558
  ...cached,
477
559
  cachedAt: Date.now()
478
560
  });
479
561
  this.ctx.logger?.debug?.("SWR: 差分なし、TTL をリセット", {
480
- operation: "getItem:bg",
562
+ operation: "get:bg",
481
563
  slug,
482
564
  collection: this.ctx.collection
483
565
  });
@@ -498,19 +580,19 @@ var CollectionClientImpl = class {
498
580
  items,
499
581
  cachedAt: Date.now()
500
582
  };
501
- await this.ctx.docCache.setList(listEntry);
583
+ await this.ctx.docCache.setList(this.ctx.collection, listEntry);
502
584
  this.ctx.logger?.debug?.("SWR: リスト差分を検出、キャッシュを差し替え", {
503
- operation: "getList:bg",
585
+ operation: "list:bg",
504
586
  collection: this.ctx.collection
505
587
  });
506
588
  this.ctx.hooks.onListCacheRevalidated?.(listEntry);
507
589
  } else if (this.ctx.ttlMs !== void 0) {
508
- await this.ctx.docCache.setList({
590
+ await this.ctx.docCache.setList(this.ctx.collection, {
509
591
  ...cached,
510
592
  cachedAt: Date.now()
511
593
  });
512
594
  this.ctx.logger?.debug?.("SWR: リスト差分なし、TTL をリセット", {
513
- operation: "getList:bg",
595
+ operation: "list:bg",
514
596
  collection: this.ctx.collection
515
597
  });
516
598
  }
@@ -525,7 +607,7 @@ var CollectionClientImpl = class {
525
607
  return withRetry(() => this.ctx.source.list({ publishedStatuses: this.ctx.publishedStatuses.length > 0 ? this.ctx.publishedStatuses : void 0 }), {
526
608
  ...this.ctx.retryConfig,
527
609
  onRetry: (attempt, status) => {
528
- this.ctx.logger?.warn?.("getList() リトライ中", {
610
+ this.ctx.logger?.warn?.("list() リトライ中", {
529
611
  attempt,
530
612
  status
531
613
  });
@@ -536,15 +618,14 @@ var CollectionClientImpl = class {
536
618
  const retryOpts = {
537
619
  ...this.ctx.retryConfig,
538
620
  onRetry: (attempt, status) => {
539
- this.ctx.logger?.warn?.("getItem() リトライ中", {
621
+ this.ctx.logger?.warn?.("get() リトライ中", {
540
622
  attempt,
541
623
  status,
542
624
  slug
543
625
  });
544
626
  }
545
627
  };
546
- const slugField = this.ctx.slugField;
547
- const notionPropName = slugField ? this.ctx.source.properties?.[slugField]?.notion : void 0;
628
+ const notionPropName = this.ctx.source.properties?.[this.ctx.slugField]?.notion;
548
629
  let item;
549
630
  const findByProp = this.ctx.source.findByProp?.bind(this.ctx.source);
550
631
  if (notionPropName && findByProp) item = await withRetry(() => findByProp(notionPropName, slug), retryOpts);
@@ -554,11 +635,11 @@ var CollectionClientImpl = class {
554
635
  return item;
555
636
  }
556
637
  };
557
- function applyGetListOptions(items, opts) {
638
+ function applyListOptions(items, opts) {
558
639
  if (!opts) return items;
559
640
  let result = items;
560
- if (opts.statuses && opts.statuses.length > 0) {
561
- const allow = new Set(opts.statuses);
641
+ if (opts.status) {
642
+ const allow = new Set(Array.isArray(opts.status) ? opts.status : [opts.status]);
562
643
  result = result.filter((it) => it.status !== void 0 && allow.has(it.status));
563
644
  }
564
645
  if (opts.tag) {
@@ -580,7 +661,7 @@ function applyGetListOptions(items, opts) {
580
661
  }
581
662
  function makeComparator(sort) {
582
663
  const by = sort.by;
583
- const dir = sort.direction === "asc" ? 1 : -1;
664
+ const dir = sort.dir === "asc" ? 1 : -1;
584
665
  return (a, b) => {
585
666
  const av = a[by];
586
667
  const bv = b[by];
@@ -648,88 +729,41 @@ function trimTrailingSlash(s) {
648
729
  return s.endsWith("/") ? s.slice(0, -1) : s;
649
730
  }
650
731
  //#endregion
651
- //#region src/preset-node.ts
652
- /**
653
- * Node.js ランタイム向けの `createCMS` オプションプリセット。
654
- * メモリキャッシュをデフォルト有効にした `{ cache, renderer }` を返す。
655
- *
656
- * @example
657
- * import { createCMS, nodePreset } from "@notion-headless-cms/core";
658
- * import { cmsDataSources } from "./generated/cms-schema";
659
- *
660
- * const cms = createCMS({
661
- * ...nodePreset({ ttlMs: 5 * 60_000 }),
662
- * dataSources: cmsDataSources,
663
- * });
664
- */
665
- function nodePreset(opts = {}) {
666
- if (opts.cache === "disabled") return {
667
- cache: void 0,
668
- renderer: opts.renderer
669
- };
670
- return {
671
- cache: opts.cache ?? {
672
- document: memoryDocumentCache(),
673
- image: memoryImageCache(),
674
- ttlMs: opts.ttlMs
675
- },
676
- renderer: opts.renderer
677
- };
678
- }
679
- //#endregion
680
732
  //#region src/cms.ts
681
733
  const DEFAULT_IMAGE_PROXY_BASE = "/api/images";
682
- function resolveDocumentCache(cache) {
683
- if (!cache || cache === "disabled" || !cache.document) return noopDocumentCache();
684
- return cache.document;
685
- }
686
- function resolveImageCache(cache) {
687
- if (!cache || cache === "disabled" || !cache.image) return noopImageCache();
688
- return cache.image;
689
- }
690
- function resolveTtl(cache) {
691
- if (!cache || cache === "disabled") return void 0;
692
- return cache.ttlMs;
693
- }
694
- function hasImageCacheConfigured(cache) {
695
- if (!cache || cache === "disabled") return false;
696
- return !!cache.image;
697
- }
698
734
  /**
699
- * `{collection}:{slug}` キー空間で動作するコレクション別キャッシュビューを生成する。
700
- * 単一の `DocumentCacheAdapter` に複数コレクションを同居させるためのアダプタ。
735
+ * `cache` オプションから document / image オペレーションを解決する。
736
+ *
737
+ * - 配列で渡された場合は各 adapter の `handles` を見て先勝ち (最初に見つかったもの) で振り分ける
738
+ * - 単体で渡された場合は `handles` の領域だけ反映、片側は noop
739
+ * - 未指定なら両方 noop
701
740
  */
702
- function scopeDocumentCache(base, collection) {
703
- const itemKey = (slug) => `${collection}:${slug}`;
704
- let listSlot = null;
705
- let listInitialized = false;
706
- return {
707
- name: `${base.name}@${collection}`,
708
- getList: async () => {
709
- if (!listInitialized) {
710
- listInitialized = true;
711
- listSlot = await base.getList();
712
- }
713
- return listSlot;
714
- },
715
- setList: (data) => {
716
- listSlot = data;
717
- listInitialized = true;
718
- return Promise.resolve();
719
- },
720
- getItem: (slug) => base.getItem(itemKey(slug)),
721
- setItem: (slug, data) => base.setItem(itemKey(slug), data),
722
- async invalidate(scope) {
723
- listSlot = null;
724
- listInitialized = true;
725
- if (!base.invalidate) return;
726
- if (scope === "all") return base.invalidate({ collection });
727
- if ("slug" in scope) return base.invalidate({
728
- collection: scope.collection,
729
- slug: itemKey(scope.slug)
730
- });
731
- return base.invalidate(scope);
741
+ function resolveCache(cache) {
742
+ const adapters = cache === void 0 ? [] : Array.isArray(cache) ? cache : [cache];
743
+ let doc = noopDocOps;
744
+ let docName = "noop-document";
745
+ let img = noopImgOps;
746
+ let imgName = "noop-image";
747
+ let docFound = false;
748
+ let imgFound = false;
749
+ for (const adapter of adapters) {
750
+ if (!docFound && adapter.handles.includes("document") && adapter.doc) {
751
+ doc = adapter.doc;
752
+ docName = adapter.name;
753
+ docFound = true;
754
+ }
755
+ if (!imgFound && adapter.handles.includes("image") && adapter.img) {
756
+ img = adapter.img;
757
+ imgName = adapter.name;
758
+ imgFound = true;
732
759
  }
760
+ }
761
+ return {
762
+ doc,
763
+ docName,
764
+ img,
765
+ imgName,
766
+ hasImg: imgFound
733
767
  };
734
768
  }
735
769
  const LOG_LEVEL_ORDER = {
@@ -752,68 +786,53 @@ function applyLogLevel(logger, minLevel) {
752
786
  return filtered;
753
787
  }
754
788
  /**
755
- * `preset` オプションを解決して `cache` / `renderer` のデフォルトを補完する内部関数。
756
- * 明示的な `cache` / `renderer` がある場合はそちらが優先される。
757
- * `preset` 未指定時は opts をそのまま返す。
758
- */
759
- function resolvePreset(opts) {
760
- if (opts.preset === "disabled") return {
761
- ...opts,
762
- cache: void 0
763
- };
764
- if (opts.preset === "node") {
765
- const presetResult = nodePreset({ ttlMs: opts.ttlMs });
766
- return {
767
- ...opts,
768
- cache: opts.cache ?? presetResult.cache,
769
- renderer: opts.renderer ?? presetResult.renderer
770
- };
771
- }
772
- return opts;
773
- }
774
- /**
775
- * 複数の DataSource を束ねた CMS クライアントを生成する。
776
- *
777
- * @example
778
- * // Node.js(preset を使った簡潔な記法)
779
- * const cms = createCMS({ dataSources: cmsDataSources, preset: "node", ttlMs: 5 * 60_000 });
789
+ * 複数の `CollectionDef` を束ねた CMS クライアントを生成する。
780
790
  *
781
- * @example
782
- * // 従来の spread パターン(引き続き動作する)
783
- * const cms = createCMS({ ...nodePreset({ ttlMs: 5 * 60_000 }), dataSources: cmsDataSources });
791
+ * 通常はユーザーが直接呼ぶことはなく、CLI 生成の `nhc.ts` の `createCMS`
792
+ * (低レベルのこの関数をラップしたもの) を経由する。
784
793
  *
785
794
  * @example
786
- * // キャッシュを細かく指定する場合
787
- * const cms = createCMS({
788
- * dataSources,
789
- * cache: { document, image, ttlMs: 60_000 },
795
+ * createCMS({
796
+ * collections: {
797
+ * posts: {
798
+ * source: createNotionCollection({ token, dataSourceId, properties }),
799
+ * slugField: "slug",
800
+ * statusField: "status",
801
+ * publishedStatuses: ["公開済み"],
802
+ * }
803
+ * },
804
+ * cache: memoryCache({ ttlMs: 5 * 60_000 }),
790
805
  * });
791
806
  */
792
807
  function createCMS(opts) {
793
- if (!opts.dataSources || Object.keys(opts.dataSources).length === 0) throw new CMSError({
808
+ if (!opts.collections || Object.keys(opts.collections).length === 0) throw new CMSError({
794
809
  code: "core/config_invalid",
795
- message: "createCMS: dataSources に少なくとも1つのコレクションを指定してください。",
810
+ message: "createCMS: collections に少なくとも 1 つのコレクションを指定してください。",
796
811
  context: { operation: "createCMS" }
797
812
  });
798
- for (const [name, col] of Object.entries(opts.collections ?? {})) {
799
- const c = col;
800
- if (c && !c.slug) throw new CMSError({
813
+ for (const [name, def] of Object.entries(opts.collections)) {
814
+ if (!def.source) throw new CMSError({
815
+ code: "core/config_invalid",
816
+ message: `createCMS: コレクション "${name}" の source は必須です。`,
817
+ context: {
818
+ operation: "createCMS",
819
+ collection: name
820
+ }
821
+ });
822
+ if (!def.slugField) throw new CMSError({
801
823
  code: "core/config_invalid",
802
- message: `createCMS: コレクション "${name}" の collections.slug は必須です。slug として使うフィールド名を指定してください。`,
824
+ message: `createCMS: コレクション "${name}" の slugField は必須です。`,
803
825
  context: {
804
826
  operation: "createCMS",
805
827
  collection: name
806
828
  }
807
829
  });
808
830
  }
809
- const resolved = resolvePreset(opts);
810
- const baseDocCache = resolveDocumentCache(resolved.cache);
811
- const imgCache = resolveImageCache(resolved.cache);
812
- const hasImageCache = hasImageCacheConfigured(resolved.cache);
813
- const ttlMs = resolveTtl(resolved.cache);
814
- const imageProxyBase = opts.content?.imageProxyBase ?? DEFAULT_IMAGE_PROXY_BASE;
831
+ const cacheRes = resolveCache(opts.cache);
832
+ const ttlMs = opts.ttlMs;
833
+ const imageProxyBase = opts.imageProxyBase ?? DEFAULT_IMAGE_PROXY_BASE;
815
834
  const contentConfig = opts.content;
816
- const rendererFn = resolved.renderer;
835
+ const rendererFn = opts.renderer;
817
836
  const waitUntil = opts.waitUntil;
818
837
  const baseLogger = mergeLoggers(opts.plugins ?? [], opts.logger);
819
838
  const logger = opts.logLevel ? applyLogLevel(baseLogger, opts.logLevel) : baseLogger;
@@ -823,62 +842,59 @@ function createCMS(opts) {
823
842
  ...DEFAULT_RETRY_CONFIG,
824
843
  ...opts.rateLimiter ?? {}
825
844
  };
826
- const collectionNames = Object.keys(opts.dataSources);
845
+ const collectionNames = Object.keys(opts.collections);
827
846
  const collections = {};
828
- const scopedCaches = [];
829
847
  for (const name of collectionNames) {
830
- const source = opts.dataSources[name];
831
- const scopedCache = scopeDocumentCache(baseDocCache, name);
832
- scopedCaches.push(scopedCache);
833
- const col = opts.collections?.[name];
834
- const colHooks = col?.hooks;
848
+ const def = opts.collections[name];
849
+ const source = def.source;
850
+ const colHooks = def.hooks;
835
851
  const collectionHooks = colHooks ? mergeHooks([{
836
852
  name: `${name}:global`,
837
853
  hooks
838
854
  }], colHooks, logger) : hooks;
855
+ const renderCtx = {
856
+ source,
857
+ rendererFn,
858
+ imgCache: cacheRes.img,
859
+ imgCacheName: cacheRes.imgName,
860
+ hasImageCache: cacheRes.hasImg,
861
+ imageProxyBase,
862
+ contentConfig,
863
+ hooks: collectionHooks,
864
+ logger
865
+ };
839
866
  collections[name] = new CollectionClientImpl({
840
867
  collection: name,
841
868
  source,
842
- docCache: scopedCache,
843
- render: {
844
- source,
845
- rendererFn,
846
- imgCache,
847
- hasImageCache,
848
- imageProxyBase,
849
- contentConfig,
850
- hooks: collectionHooks,
851
- logger
852
- },
869
+ docCache: cacheRes.doc,
870
+ docCacheName: cacheRes.docName,
871
+ render: renderCtx,
853
872
  hooks: collectionHooks,
854
873
  logger,
855
874
  ttlMs,
856
- publishedStatuses: col?.publishedStatuses ? [...col.publishedStatuses] : [],
857
- accessibleStatuses: col?.accessibleStatuses ? [...col.accessibleStatuses] : [],
875
+ publishedStatuses: def.publishedStatuses ? [...def.publishedStatuses] : [],
876
+ accessibleStatuses: def.accessibleStatuses ? [...def.accessibleStatuses] : [],
858
877
  retryConfig,
859
878
  maxConcurrent,
860
879
  waitUntil,
861
- slugField: col?.slug
880
+ slugField: def.slugField
862
881
  });
863
882
  }
864
883
  const globalOps = {
865
884
  $collections: collectionNames,
866
- async $revalidate(scope) {
885
+ async $invalidate(scope) {
867
886
  logger?.debug?.("グローバルキャッシュを無効化", {
868
- operation: "$revalidate",
869
- cacheAdapter: baseDocCache.name
887
+ operation: "$invalidate",
888
+ cacheAdapter: cacheRes.docName
870
889
  });
871
- for (const cache of scopedCaches) {
872
- if (!cache.invalidate) continue;
873
- await cache.invalidate(scope ?? "all");
874
- }
890
+ await cacheRes.doc.invalidate(scope ?? "all");
875
891
  },
876
892
  $handler(handlerOpts) {
877
893
  return createHandler({
878
- imageCache: imgCache,
894
+ imageCache: cacheRes.img,
879
895
  parseWebhook: async (req, webhookSecret) => {
880
896
  for (const name of collectionNames) {
881
- const ds = opts.dataSources[name];
897
+ const ds = opts.collections[name].source;
882
898
  if (ds.parseWebhook) try {
883
899
  return await ds.parseWebhook(req.clone(), { secret: webhookSecret });
884
900
  } catch (err) {
@@ -898,11 +914,11 @@ function createCMS(opts) {
898
914
  } catch {}
899
915
  return null;
900
916
  },
901
- revalidate: (scope) => globalOps.$revalidate(scope)
917
+ revalidate: (scope) => globalOps.$invalidate(scope)
902
918
  }, handlerOpts);
903
919
  },
904
920
  $getCachedImage(hash) {
905
- return imgCache.get(hash);
921
+ return cacheRes.img.get(hash);
906
922
  }
907
923
  };
908
924
  return Object.assign(Object.create(null), collections, globalOps);
@@ -913,6 +929,6 @@ function definePlugin(plugin) {
913
929
  return plugin;
914
930
  }
915
931
  //#endregion
916
- export { CMSError, CollectionClientImpl, DEFAULT_RETRY_CONFIG, collectionKey, createCMS, createHandler, definePlugin, isCMSError, isCMSErrorInNamespace, isStale, memoryDocumentCache, memoryImageCache, mergeHooks, mergeLoggers, nodePreset, noopDocumentCache, noopImageCache, sha256Hex, withRetry };
932
+ export { CMSError, CollectionClientImpl, DEFAULT_RETRY_CONFIG, collectionKey, createCMS, createHandler, definePlugin, isCMSError, isCMSErrorInNamespace, isStale, memoryCache, mergeHooks, mergeLoggers, noopDocOps, noopImgOps, sha256Hex, withRetry };
917
933
 
918
934
  //# sourceMappingURL=index.mjs.map