@nifrajs/content 5.4.0 → 5.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @nifrajs/content
2
2
 
3
+ ## 5.5.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 6ca916c: A collection with `watch` on re-reads a file that was replaced in place with the same size and modification time, as a deploy or sync tool does when it preserves times. Its cache recognised a file by size and mtime alone, so `get()` and `all()` kept serving the old content until either changed. The cache now also compares the file's device, inode and change time.
8
+
3
9
  ## 5.4.0
4
10
 
5
11
  No changes in this release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/content",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "description": "Typed, schema-validated content collections for nifra - Markdown + frontmatter, framework-agnostic.",
5
5
  "keywords": [
6
6
  "bun",
package/src/fs.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * gives you a typed, validated API over them. Build/server-time (Bun/Node/Deno); for the edge, bake the
4
4
  * entries at build (the manifest approach `@nifrajs/web/fs` uses for routes) - tracked as a follow-up.
5
5
  */
6
- import type { Stats } from "node:fs"
6
+ import type { BigIntStats } from "node:fs"
7
7
  import { readdir, readFile, stat as statPath } from "node:fs/promises"
8
8
  import { resolve, sep } from "node:path"
9
9
  import { type Entry, type InferSchema, parseEntry, type StandardSchemaV1 } from "./index.ts"
@@ -51,6 +51,12 @@ const assertSafeExtensions = (extensions: readonly string[]): void => {
51
51
  }
52
52
  }
53
53
 
54
+ // File identity and ctime as well as mtime and size: an atomic replace that keeps both the size and
55
+ // the mtime (`touch -r`, a sync tool preserving times) still swaps the inode and moves the ctime.
56
+ // BigInt stats, because a 64-bit file id (Windows) and a nanosecond time do not fit a double.
57
+ const stampOf = (stat: BigIntStats): string =>
58
+ `${stat.dev}:${stat.ino}:${stat.ctimeNs}:${stat.mtimeNs}:${stat.size}`
59
+
54
60
  const insideRoot = (root: string, file: string): boolean =>
55
61
  file !== root && file.startsWith(`${root}${sep}`)
56
62
 
@@ -109,8 +115,8 @@ export function defineCollection<S extends StandardSchemaV1>(
109
115
  return Promise.all(
110
116
  files.map(async (file) => {
111
117
  const path = resolve(root, file)
112
- const stat = await statPath(path)
113
- return { file, path, stamp: `${stat.mtimeMs}:${stat.size}` }
118
+ const stat = await statPath(path, { bigint: true })
119
+ return { file, path, stamp: stampOf(stat) }
114
120
  }),
115
121
  )
116
122
  }
@@ -148,16 +154,16 @@ export function defineCollection<S extends StandardSchemaV1>(
148
154
  for (const ext of extensions) {
149
155
  const file = resolve(root, `${slug}${ext}`)
150
156
  if (!insideRoot(root, file)) return null
151
- let stat: Stats
157
+ let stat: BigIntStats
152
158
  try {
153
- stat = await statPath(file)
159
+ stat = await statPath(file, { bigint: true })
154
160
  } catch {
155
161
  fileCache.delete(file)
156
162
  continue // Not this extension; try the next configured one.
157
163
  }
158
164
  if (!stat.isFile()) continue
159
165
  // Outside the try: a file that exists but fails to parse is an error, not a missing entry.
160
- return await readEntry(file, slug, `${stat.mtimeMs}:${stat.size}`)
166
+ return await readEntry(file, slug, stampOf(stat))
161
167
  }
162
168
  return null
163
169
  },