@glw907/cairn-cms 0.62.1 → 0.62.2

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
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to this project are recorded here, most recent first.
4
4
 
5
+ ## 0.62.2
6
+
7
+ The edit-load address-collision advisory now checks the published corpus only. It fires when an entry
8
+ you are editing collides with an entry already published on `main`, and it no longer reads sibling
9
+ `cairn/<concept>/<id>` branches when an editor opens an entry, so opening the editor adds no GitHub
10
+ reads. The publish-time re-check is unchanged: it stays full cross-branch and still emits the
11
+ `publish.address_collision` log event when a publish overrides another entry's address. No consumer
12
+ action is required.
13
+
5
14
  ## 0.62.1
6
15
 
7
16
  The entry editor gains an advisory channel and its first notice: a cross-branch address-collision
@@ -32,6 +32,11 @@ export interface AddressEntry {
32
32
  }
33
33
  /** Permalink to the distinct entries that resolve to it, across main and every open branch. */
34
34
  export type AddressIndex = Map<string, AddressEntry[]>;
35
+ /**
36
+ * The address index over main only: a synchronous reverse map of each manifest entry's resolved
37
+ * permalink. No backend read, so an edit-load can build it for free from the manifest it already holds.
38
+ */
39
+ export declare function mainAddressIndex(manifest: Manifest): AddressIndex;
35
40
  /**
36
41
  * Build the permalink-keyed address index over main (from each manifest entry's resolved permalink)
37
42
  * plus every open cairn/* branch (resolved from its edited markdown).
@@ -14,17 +14,11 @@ function push(index, permalink, entry) {
14
14
  index.set(permalink, [entry]);
15
15
  }
16
16
  /**
17
- * Build the permalink-keyed address index over main (from each manifest entry's resolved permalink)
18
- * plus every open cairn/* branch (resolved from its edited markdown).
19
- *
20
- * The build fails open: a branch read that throws and a permalink that cannot resolve are both caught
21
- * and skipped, so a transient failure degrades to a thinner index, never a thrown editor or a blocked
22
- * publish. The branches are read in one Promise.all, the way buildUsageIndex reads them.
17
+ * The address index over main only: a synchronous reverse map of each manifest entry's resolved
18
+ * permalink. No backend read, so an edit-load can build it for free from the manifest it already holds.
23
19
  */
24
- export async function buildAddressIndex(repo, token, concepts, manifest) {
20
+ export function mainAddressIndex(manifest) {
25
21
  const index = new Map();
26
- // The main arm: the manifest already carries each entry's resolved permalink, so this is a pure
27
- // reverse map with no per-file read.
28
22
  for (const entry of manifest.entries) {
29
23
  push(index, entry.permalink, {
30
24
  concept: entry.concept,
@@ -33,6 +27,20 @@ export async function buildAddressIndex(repo, token, concepts, manifest) {
33
27
  source: 'main',
34
28
  });
35
29
  }
30
+ return index;
31
+ }
32
+ /**
33
+ * Build the permalink-keyed address index over main (from each manifest entry's resolved permalink)
34
+ * plus every open cairn/* branch (resolved from its edited markdown).
35
+ *
36
+ * The build fails open: a branch read that throws and a permalink that cannot resolve are both caught
37
+ * and skipped, so a transient failure degrades to a thinner index, never a thrown editor or a blocked
38
+ * publish. The branches are read in one Promise.all, the way buildUsageIndex reads them.
39
+ */
40
+ export async function buildAddressIndex(repo, token, concepts, manifest) {
41
+ // The main arm: the manifest already carries each entry's resolved permalink, so seed from the
42
+ // synchronous main-only index and union the branch arm on top.
43
+ const index = mainAddressIndex(manifest);
36
44
  // The branch arm: read each open cairn/* branch's one edited file and resolve its permalink. The
37
45
  // path is derivable from the branch name, so no tree-listing is needed.
38
46
  const names = await listBranches(repo, PENDING_PREFIX, token);
@@ -8,7 +8,7 @@ import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../conten
8
8
  import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
9
9
  import { deriveExcerpt } from '../content/excerpt.js';
10
10
  import { asString, entryIdentity } from '../content/identity.js';
11
- import { buildAddressIndex, addressCollision } from '../content/advisories.js';
11
+ import { buildAddressIndex, mainAddressIndex, addressCollision } from '../content/advisories.js';
12
12
  import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
13
13
  import { appCredentials } from '../github/credentials.js';
14
14
  import { listMarkdown, readRaw, commitFile, commitFiles } from '../github/repo.js';
@@ -502,14 +502,16 @@ export function createContentRoutes(runtime, deps = {}) {
502
502
  }));
503
503
  inbound = inboundLinks(manifest, concept.id, id);
504
504
  }
505
- // The cross-branch address-collision advisory: warn-and-allow, never a gate. Build it from the
506
- // same manifest read above (no second read) and degrade to no notice on any read failure, so a
507
- // transient GitHub error never blocks the editor. Skip the build with no manifest to index.
505
+ // The address-collision advisory: warn-and-allow, never a gate. At edit-load it checks the
506
+ // published corpus only, built synchronously from the same manifest read above (no extra GitHub
507
+ // read per editor open); publishAction re-checks the full cross-branch index before it lands. The
508
+ // try/catch degrades to no notice if entryIdentity throws on a malformed-date entry. Skip the build
509
+ // with no manifest to index.
508
510
  let advisories = [];
509
511
  if (manifest !== null) {
510
512
  try {
511
513
  const identity = entryIdentity(concept, path, parsed.frontmatter);
512
- const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
514
+ const addressIndex = mainAddressIndex(manifest);
513
515
  const other = addressCollision(addressIndex, { concept: concept.id, id }, identity.permalink);
514
516
  if (other) {
515
517
  const otherConcept = findConcept(runtime.concepts, other.concept);
@@ -524,8 +526,8 @@ export function createContentRoutes(runtime, deps = {}) {
524
526
  ];
525
527
  }
526
528
  }
527
- catch (err) {
528
- log.warn('github.unreachable', { scope: 'edit-advisories', error: String(err) });
529
+ catch {
530
+ // A malformed-date entry that cannot resolve its permalink degrades to no advisory, fail open.
529
531
  }
530
532
  }
531
533
  // Project the one committed media manifest read two ways: the minimal resolver triple the preview
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.62.1",
3
+ "version": "0.62.2",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -1,7 +1,7 @@
1
1
  // cairn-cms: the entry editor's internal advisory channel (editor-help pass 3). An advisory is a
2
2
  // non-blocking, serializable notice that rides EditData across the SSR boundary, so it carries data
3
- // only and never a callback. Today's one notice is the cross-branch address collision: a warning,
4
- // not a gate, that another entry already resolves to the same public address (last-write-wins).
3
+ // only and never a callback. Today's one notice is the address collision: a warning, not a gate,
4
+ // that another entry already resolves to the same public address (last-write-wins).
5
5
  //
6
6
  // The address index mirrors buildUsageIndex (src/lib/media/usage.ts): a main arm that reads each
7
7
  // manifest entry's resolved permalink with no per-file read, and a branch arm that lists every open
@@ -9,7 +9,8 @@
9
9
  // and resolves its permalink. The map is keyed by permalink, so every entry that resolves to a given
10
10
  // address shares one bucket. The build fails open: a branch read that throws, or a dated entry whose
11
11
  // permalink cannot resolve, is skipped rather than thrown, so a transient failure degrades to no
12
- // notice and never blocks the editor or the publish.
12
+ // notice and never blocks the editor or the publish. The scope splits by call site: the main arm at
13
+ // edit-load (synchronous, no extra GitHub read per open) and the full cross-branch check at publish.
13
14
  import type { ConceptDescriptor } from './types.js';
14
15
  import type { RepoRef } from '../github/types.js';
15
16
  import type { Manifest } from './manifest.js';
@@ -63,6 +64,23 @@ function push(index: AddressIndex, permalink: string, entry: AddressEntry): void
63
64
  else index.set(permalink, [entry]);
64
65
  }
65
66
 
67
+ /**
68
+ * The address index over main only: a synchronous reverse map of each manifest entry's resolved
69
+ * permalink. No backend read, so an edit-load can build it for free from the manifest it already holds.
70
+ */
71
+ export function mainAddressIndex(manifest: Manifest): AddressIndex {
72
+ const index: AddressIndex = new Map();
73
+ for (const entry of manifest.entries) {
74
+ push(index, entry.permalink, {
75
+ concept: entry.concept,
76
+ id: entry.id,
77
+ title: entry.title,
78
+ source: 'main',
79
+ });
80
+ }
81
+ return index;
82
+ }
83
+
66
84
  /**
67
85
  * Build the permalink-keyed address index over main (from each manifest entry's resolved permalink)
68
86
  * plus every open cairn/* branch (resolved from its edited markdown).
@@ -77,18 +95,9 @@ export async function buildAddressIndex(
77
95
  concepts: ConceptDescriptor[],
78
96
  manifest: Manifest,
79
97
  ): Promise<AddressIndex> {
80
- const index: AddressIndex = new Map();
81
-
82
- // The main arm: the manifest already carries each entry's resolved permalink, so this is a pure
83
- // reverse map with no per-file read.
84
- for (const entry of manifest.entries) {
85
- push(index, entry.permalink, {
86
- concept: entry.concept,
87
- id: entry.id,
88
- title: entry.title,
89
- source: 'main',
90
- });
91
- }
98
+ // The main arm: the manifest already carries each entry's resolved permalink, so seed from the
99
+ // synchronous main-only index and union the branch arm on top.
100
+ const index = mainAddressIndex(manifest);
92
101
 
93
102
  // The branch arm: read each open cairn/* branch's one edited file and resolve its permalink. The
94
103
  // path is derivable from the branch name, so no tree-listing is needed.
@@ -8,7 +8,7 @@ import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../conten
8
8
  import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
9
9
  import { deriveExcerpt } from '../content/excerpt.js';
10
10
  import { asString, entryIdentity } from '../content/identity.js';
11
- import { buildAddressIndex, addressCollision, type AdvisoryNotice, type AddressEntry } from '../content/advisories.js';
11
+ import { buildAddressIndex, mainAddressIndex, addressCollision, type AdvisoryNotice, type AddressEntry } from '../content/advisories.js';
12
12
  import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
13
13
  import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
14
14
  import { listMarkdown, readRaw, commitFile, commitFiles, type FileChange } from '../github/repo.js';
@@ -1072,14 +1072,16 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1072
1072
  inbound = inboundLinks(manifest, concept.id, id);
1073
1073
  }
1074
1074
 
1075
- // The cross-branch address-collision advisory: warn-and-allow, never a gate. Build it from the
1076
- // same manifest read above (no second read) and degrade to no notice on any read failure, so a
1077
- // transient GitHub error never blocks the editor. Skip the build with no manifest to index.
1075
+ // The address-collision advisory: warn-and-allow, never a gate. At edit-load it checks the
1076
+ // published corpus only, built synchronously from the same manifest read above (no extra GitHub
1077
+ // read per editor open); publishAction re-checks the full cross-branch index before it lands. The
1078
+ // try/catch degrades to no notice if entryIdentity throws on a malformed-date entry. Skip the build
1079
+ // with no manifest to index.
1078
1080
  let advisories: AdvisoryNotice[] = [];
1079
1081
  if (manifest !== null) {
1080
1082
  try {
1081
1083
  const identity = entryIdentity(concept, path, parsed.frontmatter);
1082
- const addressIndex = await buildAddressIndex(runtime.backend, token, runtime.concepts, manifest);
1084
+ const addressIndex = mainAddressIndex(manifest);
1083
1085
  const other = addressCollision(addressIndex, { concept: concept.id, id }, identity.permalink);
1084
1086
  if (other) {
1085
1087
  const otherConcept = findConcept(runtime.concepts, other.concept);
@@ -1093,8 +1095,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
1093
1095
  },
1094
1096
  ];
1095
1097
  }
1096
- } catch (err) {
1097
- log.warn('github.unreachable', { scope: 'edit-advisories', error: String(err) });
1098
+ } catch {
1099
+ // A malformed-date entry that cannot resolve its permalink degrades to no advisory, fail open.
1098
1100
  }
1099
1101
  }
1100
1102