Skip to content

Commit 983582f

Browse files
authored
Added RouteSettings type model, store contract and in-memory adapter (#28821)
ref HKG-1825 - RouteSettings is the canonical domain model for routes.yaml — a flat typed structure that the new RouteSettingsService will use instead of the untyped objects that validate.js produces today - Domain model stores what the user wrote ({slug} notation, short-form data, templates as string[]) so round-tripping through any future store is lossless — no expansion or normalisation at the storage layer - Store contract (get/replace) defines the interface that FileStore and GCS will implement — foundation for swapping storage backends - Contract tests verify immutability semantics so no future store implementation can leak internal references to callers
1 parent dab80c2 commit 983582f

6 files changed

Lines changed: 213 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// PascalCase mirrors the runtime `RouteSettingsStoreBase.js` so the TS adapter
2+
// can default-import via the matching declaration file.
3+
import type {RouteSettings} from '../../services/route-settings/route-settings-parser';
4+
5+
/**
6+
* Concurrent `replace` calls have no ordering guarantee — serialize
7+
* externally if that matters.
8+
*/
9+
export interface RouteSettingsStore {
10+
get(): Promise<RouteSettings>;
11+
replace(settings: RouteSettings): Promise<void>;
12+
}
13+
14+
declare class RouteSettingsStoreBase {
15+
readonly requiredFns: ReadonlyArray<string>;
16+
}
17+
18+
export default RouteSettingsStoreBase;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module.exports = class RouteSettingsStoreBase {
2+
constructor() {
3+
Object.defineProperty(this, 'requiredFns', {
4+
value: ['get', 'replace'],
5+
writable: false
6+
});
7+
}
8+
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
export type DataShortFormResource = 'tag' | 'page' | 'post' | 'author';
2+
export type DataLongFormResource = 'tags' | 'posts' | 'pages' | 'authors';
3+
4+
export type DataShortForm = `${DataShortFormResource}.${string}`;
5+
6+
export interface DataReadEntry {
7+
type: 'read';
8+
resource: DataLongFormResource;
9+
slug: string;
10+
redirect?: boolean;
11+
include?: string;
12+
visibility?: string;
13+
status?: string;
14+
}
15+
16+
export interface DataBrowseEntry {
17+
type: 'browse';
18+
resource: DataLongFormResource;
19+
filter?: string;
20+
limit?: number | 'all';
21+
order?: string;
22+
include?: string;
23+
fields?: string;
24+
visibility?: string;
25+
status?: string;
26+
page?: number;
27+
}
28+
29+
export type DataLongFormEntry = DataReadEntry | DataBrowseEntry;
30+
export type DataEntry = DataShortForm | DataLongFormEntry;
31+
export type RouteData = DataShortForm | Record<string, DataEntry>;
32+
33+
interface RouteBase {
34+
path: string;
35+
templates?: string[];
36+
data?: RouteData;
37+
}
38+
39+
export interface ChannelRoute extends RouteBase {
40+
type: 'channel';
41+
filter?: string;
42+
order?: string;
43+
limit?: number | 'all';
44+
rss: boolean;
45+
}
46+
47+
export interface TemplateRoute extends RouteBase {
48+
type: 'template';
49+
contentType?: string;
50+
}
51+
52+
export type Route = ChannelRoute | TemplateRoute;
53+
54+
export interface CollectionConfig {
55+
path: string;
56+
permalink: string;
57+
templates?: string[];
58+
filter?: string;
59+
order?: string;
60+
limit?: number | 'all';
61+
rss?: boolean;
62+
data?: RouteData;
63+
}
64+
65+
export interface TaxonomyConfig {
66+
tag?: string;
67+
author?: string;
68+
}
69+
70+
export interface RouteSettings {
71+
routes: Route[];
72+
collections: CollectionConfig[];
73+
taxonomies: TaxonomyConfig;
74+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type {RouteSettings} from '../../../../../../core/server/services/route-settings/route-settings-parser';
2+
import type {RouteSettingsStore} from '../../../../../../core/server/adapters/route-settings/RouteSettingsStoreBase';
3+
4+
export class InMemoryStore implements RouteSettingsStore {
5+
private settings: RouteSettings = {routes: [], collections: [], taxonomies: {}};
6+
7+
async get(): Promise<RouteSettings> {
8+
return structuredClone(this.settings);
9+
}
10+
11+
async replace(settings: RouteSettings): Promise<void> {
12+
this.settings = structuredClone(settings);
13+
}
14+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import assert from 'node:assert/strict';
2+
3+
import type {RouteSettings} from '../../../../../../core/server/services/route-settings/route-settings-parser';
4+
import type {RouteSettingsStore} from '../../../../../../core/server/adapters/route-settings/RouteSettingsStoreBase';
5+
6+
interface ContractOptions {
7+
createStore: () => RouteSettingsStore | Promise<RouteSettingsStore>;
8+
}
9+
10+
const emptySettings = (): RouteSettings => ({routes: [], collections: [], taxonomies: {}});
11+
12+
const sampleSettings = (): RouteSettings => ({
13+
routes: [
14+
{type: 'template', path: '/about/', templates: ['about']}
15+
],
16+
collections: [
17+
{
18+
path: '/',
19+
permalink: '/{slug}/',
20+
templates: ['index'],
21+
data: {featured: {resource: 'posts', type: 'browse', filter: 'featured:true'}}
22+
}
23+
],
24+
taxonomies: {
25+
tag: '/tag/{slug}/'
26+
}
27+
});
28+
29+
export function runStoreContract({createStore}: ContractOptions): void {
30+
describe('RouteSettingsStore contract', function () {
31+
let store: RouteSettingsStore;
32+
33+
beforeEach(async function () {
34+
store = await createStore();
35+
});
36+
37+
describe('get', function () {
38+
it('returns settings that callers cannot mutate in place', async function () {
39+
await store.replace(sampleSettings());
40+
41+
const firstRead = await store.get();
42+
firstRead.routes.push({type: 'template', path: '/x/', templates: ['x']});
43+
firstRead.collections[0].permalink = '/mutated/';
44+
45+
assert.deepEqual(await store.get(), sampleSettings());
46+
});
47+
});
48+
49+
describe('replace', function () {
50+
it('persists settings so get returns the same RouteSettings', async function () {
51+
const settings = sampleSettings();
52+
53+
await store.replace(settings);
54+
55+
assert.deepEqual(await store.get(), settings);
56+
});
57+
58+
it('overwrites previously stored settings rather than merging', async function () {
59+
await store.replace(sampleSettings());
60+
await store.replace({
61+
routes: [],
62+
collections: [{path: '/blog/', permalink: '/blog/{slug}/', templates: ['index']}],
63+
taxonomies: {}
64+
});
65+
66+
assert.deepEqual(await store.get(), {
67+
routes: [],
68+
collections: [{path: '/blog/', permalink: '/blog/{slug}/', templates: ['index']}],
69+
taxonomies: {}
70+
});
71+
});
72+
73+
it('clears all settings when called with empty settings', async function () {
74+
await store.replace(sampleSettings());
75+
await store.replace(emptySettings());
76+
77+
assert.deepEqual(await store.get(), emptySettings());
78+
});
79+
80+
it('does not retain a reference to the input (caller mutations do not leak)', async function () {
81+
const settings = sampleSettings();
82+
83+
await store.replace(settings);
84+
settings.routes.push({type: 'template', path: '/x/', templates: ['x']});
85+
settings.taxonomies.tag = '/mutated/';
86+
87+
assert.deepEqual(await store.get(), sampleSettings());
88+
});
89+
});
90+
});
91+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import {InMemoryStore} from './helpers/in-memory-store';
2+
import {runStoreContract} from './helpers/store-contract';
3+
4+
describe('UNIT: InMemoryStore (validates the contract)', function () {
5+
runStoreContract({
6+
createStore: () => new InMemoryStore()
7+
});
8+
});

0 commit comments

Comments
 (0)