Skip to content

Commit b065834

Browse files
committed
feat: refactor ddp publication registration into a PublicationRegistry
1 parent 36cc878 commit b065834

41 files changed

Lines changed: 1860 additions & 1447 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { PublicationRegistry } from '../../server/publicationRegistry'
2+
import { registerAllPublications } from '../../server/publicationRegistrations'
3+
4+
/**
5+
* Test helper: register all DDP publications on a fresh PublicationRegistry and apply them to the
6+
* (mock) Meteor server, mirroring what `main.ts` does at startup.
7+
*
8+
* Call this in suites that exercise Meteor subscriptions. It is needed because the production
9+
* registration now only runs explicitly from `main.ts`, rather than as an import-time side effect of
10+
* each publication file.
11+
*/
12+
export function registerAllPublicationsForTest(): PublicationRegistry {
13+
const registry = new PublicationRegistry()
14+
registerAllPublications(registry)
15+
registry.applyToMeteor()
16+
return registry
17+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`PublicationRegistry the full set of registered publication names is stable (drift guard) 1`] = `
4+
[
5+
"adLibActions",
6+
"adLibActionsForPart",
7+
"adLibPieces",
8+
"adLibPiecesForPart",
9+
"blueprints",
10+
"bucketAdLibActions",
11+
"bucketAdLibPieces",
12+
"buckets",
13+
"coreSystem",
14+
"deviceTriggersPreview",
15+
"evaluations",
16+
"expectedPackageWorkStatuses",
17+
"expectedPackages",
18+
"expectedPlayoutItemsForDevice",
19+
"externalEventSubscriptionsForDevice",
20+
"externalMessageQueue",
21+
"ingestDataCache",
22+
"ingestDeviceRundownStatus",
23+
"ingestDeviceRundownStatusTestTool",
24+
"mappingsForDevice",
25+
"mappingsForStudio",
26+
"mountedTriggersForDevice",
27+
"mountedTriggersForDevicePreview",
28+
"notificationsForRundown",
29+
"notificationsForRundownPlaylist",
30+
"packageContainerStatuses",
31+
"packageInfos",
32+
"packageManagerExpectedPackages",
33+
"packageManagerPackageContainers",
34+
"packageManagerPlayoutContext",
35+
"partInstances",
36+
"partInstancesSimple",
37+
"parts",
38+
"peripheralDeviceCommands",
39+
"peripheralDeviceForDevice",
40+
"peripheralDevices",
41+
"peripheralDevicesAndSubDevices",
42+
"pieceInstances",
43+
"pieceInstancesSimple",
44+
"pieces",
45+
"piecesInfiniteStartingBefore",
46+
"rundownBaselineAdLibActions",
47+
"rundownBaselineAdLibPieces",
48+
"rundownLayouts",
49+
"rundownPlaylistForStudio",
50+
"rundownPlaylists",
51+
"rundownsForDevice",
52+
"rundownsInPlaylists",
53+
"rundownsWithShowStyleBases",
54+
"segments",
55+
"showStyleBases",
56+
"showStyleVariants",
57+
"snapshots",
58+
"studios",
59+
"timelineDatastore",
60+
"timelineDatastoreForDevice",
61+
"timelineForDevice",
62+
"timelineForStudio",
63+
"translationsBundles",
64+
"triggeredActions",
65+
"uiBlueprintUpgradeStatuses",
66+
"uiBucketContentStatuses",
67+
"uiPartInstances",
68+
"uiParts",
69+
"uiPieceContentStatuses",
70+
"uiSegmentPartNotes",
71+
"uiShowStyleBase",
72+
"uiStudio",
73+
"uiTriggeredActions",
74+
"userActionsLog",
75+
]
76+
`;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { PublicationRegistry } from '../publicationRegistry'
2+
import { registerAllPublications } from '../publicationRegistrations'
3+
import { AllPubSubNames } from '@sofie-automation/meteor-lib/dist/api/pubsub'
4+
5+
describe('PublicationRegistry', () => {
6+
test('registers all publications without error', () => {
7+
const registry = new PublicationRegistry()
8+
// publishUnsafe throws on a duplicate name, so this is itself an assertion that no two
9+
// publications register under the same wire name.
10+
expect(() => registerAllPublications(registry)).not.toThrow()
11+
})
12+
13+
test('registered names are unique and only use known publication names', () => {
14+
const registry = new PublicationRegistry()
15+
registerAllPublications(registry)
16+
const names = registry.getAllPublicationNames()
17+
18+
// No duplicate registrations across all publication modules
19+
expect(new Set(names).size).toBe(names.length)
20+
21+
// Every registered name must be a known PubSub name.
22+
const knownNames = new Set<string>(AllPubSubNames)
23+
for (const name of names) {
24+
expect(knownNames.has(name)).toBe(true)
25+
}
26+
27+
// Conversely, every known PubSub name must be registered (the historical dev-mode check).
28+
for (const pubName of AllPubSubNames) {
29+
expect(names).toContain(pubName)
30+
}
31+
})
32+
33+
test('the full set of registered publication names is stable (drift guard)', () => {
34+
const registry = new PublicationRegistry()
35+
registerAllPublications(registry)
36+
// If this snapshot changes, a publication's wire name was added, removed or renamed. That must
37+
// be a deliberate change reviewed against the clients that depend on these exact names.
38+
expect([...registry.getAllPublicationNames()].sort()).toMatchSnapshot()
39+
})
40+
})

meteor/server/api/rest/api.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createLegacyApiRouter } from './v0/index'
1212
import { heapSnapshotPrivateApiRouter } from '../heapSnapshot'
1313
import { getRootSubpath } from '../../lib'
1414
import type { MethodRegistry } from '../../methodRegistry'
15+
import type { PublicationRegistry } from '../../publicationRegistry'
1516

1617
const LATEST_REST_API = 'v1.0'
1718

@@ -20,7 +21,7 @@ async function redirectToLatest(ctx: koa.ParameterizedContext, _next: koa.Next):
2021
ctx.status = 307
2122
}
2223

23-
export function bindRestApiRouter(methodRegistry: MethodRegistry): void {
24+
export function bindRestApiRouter(methodRegistry: MethodRegistry, publicationRegistry: PublicationRegistry): void {
2425
const apiRouter = new KoaRouter()
2526

2627
apiRouter.get('/', redirectToLatest)
@@ -45,7 +46,7 @@ export function bindRestApiRouter(methodRegistry: MethodRegistry): void {
4546
)
4647

4748
// Needs to be lazily generated
48-
const legacyApiRouter = createLegacyApiRouter(methodRegistry)
49+
const legacyApiRouter = createLegacyApiRouter(methodRegistry, publicationRegistry)
4950
apiRouter.use('/0', legacyApiRouter.routes(), legacyApiRouter.allowedMethods())
5051

5152
bindKoaRouter(apiRouter, '/api')

meteor/server/api/rest/v0/__tests__/rest.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { MeteorMock } from '../../../../../__mocks__/meteor'
22
import { Meteor } from 'meteor/meteor'
33
import { UserActionAPIMethods } from '@sofie-automation/meteor-lib/dist/api/userActions'
44
import { MethodRegistry, AnyMethodApiRegistration } from '../../../../methodRegistry'
5+
import { PublicationRegistry } from '../../../../publicationRegistry'
56
import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client'
67
import { callKoaRoute } from '../../../../../__mocks__/koa-util'
78
import { createLegacyApiRouter } from '..'
@@ -25,7 +26,8 @@ describe('REST API', () => {
2526
} as unknown as AnyMethodApiRegistration)
2627
methodRegistry.applyToMeteor() // register the methods on the (mock) Meteor server
2728
const methodSignatures = methodRegistry.getSignatures()
28-
const legacyApiRouter = createLegacyApiRouter(methodRegistry)
29+
const publicationRegistry = new PublicationRegistry()
30+
const legacyApiRouter = createLegacyApiRouter(methodRegistry, publicationRegistry)
2931

3032
test('calls the UserActionAPI methods, when doing a POST to the endpoint', async () => {
3133
for (const [methodName, methodValue] of Object.entries<any>(UserActionAPIMethods)) {

meteor/server/api/rest/v0/index.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
import _ from 'underscore'
88
import { Meteor } from 'meteor/meteor'
99
import type { MethodRegistry } from '../../../methodRegistry'
10+
import type { PublicationRegistry } from '../../../publicationRegistry'
11+
import type { PublicationContext } from '../../../publications/lib/lib'
1012
import { MeteorPubSub } from '@sofie-automation/meteor-lib/dist/api/pubsub'
11-
import { MeteorPublications, MeteorPublicationSignatures } from '../../../publications/lib/lib'
1213
import { UserActionAPIMethods } from '@sofie-automation/meteor-lib/dist/api/userActions'
1314
import { logger } from '../../../logging'
1415
import { ClientAPI } from '@sofie-automation/meteor-lib/dist/api/client'
@@ -19,6 +20,20 @@ import { PeripheralDevicePubSub } from '@sofie-automation/shared-lib/dist/pubsub
1920

2021
const LEGACY_API_VERSION = 0
2122

23+
/**
24+
* A no-op publication context for the legacy REST path: there is no live subscription, the callback is
25+
* only invoked to obtain its cursor, which is then fetched once. There is no connection, so any
26+
* publication that requires one will reject (matching the historical behaviour).
27+
*/
28+
const legacyRestPublicationContext: PublicationContext = {
29+
connection: null,
30+
onStop: () => undefined,
31+
ready: () => undefined,
32+
added: () => undefined,
33+
changed: () => undefined,
34+
removed: () => undefined,
35+
}
36+
2237
/**
2338
* Takes an array of strings and converts them to Null, Boolean, Number, String primitives or Objects, if the string
2439
* seems like a valid JSON.
@@ -54,10 +69,14 @@ function typeConvertUrlParameters(args: any[]) {
5469
return convertedArgs
5570
}
5671

57-
export function createLegacyApiRouter(methodRegistry: MethodRegistry): KoaRouter {
72+
export function createLegacyApiRouter(
73+
methodRegistry: MethodRegistry,
74+
publicationRegistry: PublicationRegistry
75+
): KoaRouter {
5876
const router = new KoaRouter()
5977

6078
const methodSignatures = methodRegistry.getSignatures()
79+
const publicationSignatures = publicationRegistry.getSignatures()
6180

6281
const index = {
6382
version: `${LEGACY_API_VERSION}`,
@@ -87,9 +106,9 @@ export function createLegacyApiRouter(methodRegistry: MethodRegistry): KoaRouter
87106
}
88107

89108
function exposePublication(pubName: string, pubValue: string) {
90-
const signature = MeteorPublicationSignatures[pubValue] || []
109+
const signature = publicationSignatures[pubValue] || []
91110

92-
const f = MeteorPublications[pubValue]
111+
const f = publicationRegistry.getCursorPublication(pubValue)
93112

94113
if (f) {
95114
let resource = `/publication/${pubName}`
@@ -103,12 +122,10 @@ export function createLegacyApiRouter(methodRegistry: MethodRegistry): KoaRouter
103122

104123
assignRoute(router, 'GET', resource, signature.length, async (args) => {
105124
const convArgs = typeConvertUrlParameters(args)
106-
const cursor = await f.apply(
107-
{
108-
ready: () => null,
109-
},
110-
convArgs
111-
)
125+
const cursor = (await f(legacyRestPublicationContext, ...convArgs)) as
126+
| { fetch: () => unknown }
127+
| null
128+
| undefined
112129

113130
if (cursor) return cursor.fetch()
114131
return []

meteor/server/lib/customPublication/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ export { CustomPublishCollection } from './customPublishCollection'
22
export { setUpOptimizedObserverArray } from './optimizedObserverArray'
33
export { TriggerUpdate, SetupObserversResult } from './optimizedObserverBase'
44
export { setUpCollectionOptimizedObserver } from './optimizedObserverCollection'
5-
export { meteorCustomPublish, CustomPublish, CustomPublishChanges } from './publish'
5+
export { CustomPublish, CustomPublishChanges } from './publish'

meteor/server/lib/customPublication/publish.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { Meteor } from 'meteor/meteor'
2-
import { AllPubSubTypes } from '@sofie-automation/meteor-lib/dist/api/pubsub'
32
import { ProtectedString, unprotectString } from '@sofie-automation/corelib/dist/protectedString'
4-
import { PublishDocType, SubscriptionContext, meteorPublishUnsafe } from '../../publications/lib/lib'
3+
import { PublicationContext } from '../../publications/lib/lib'
54

65
export interface CustomPublishChanges<T extends { _id: ProtectedString<any> }> {
76
added: Array<T>
@@ -33,7 +32,7 @@ export class CustomPublishMeteor<DBObj extends { _id: ProtectedString<any> }> {
3332
#isReady = false
3433

3534
constructor(
36-
private _meteorSubscription: SubscriptionContext,
35+
private _meteorSubscription: PublicationContext,
3736
private _collectionName: string
3837
) {
3938
this._meteorSubscription.onStop(() => {
@@ -86,19 +85,4 @@ export class CustomPublishMeteor<DBObj extends { _id: ProtectedString<any> }> {
8685
}
8786
}
8887

89-
type PublishIfDocument<Doc> = Doc extends { _id: ProtectedString<any> } ? CustomPublish<Doc> : never
90-
91-
/** Wrapping of Meteor.publish to provide types for for custom publications */
92-
export function meteorCustomPublish<K extends keyof AllPubSubTypes, N extends ReturnType<AllPubSubTypes[K]>>(
93-
publicationName: K,
94-
customCollectionName: N,
95-
cb: (
96-
this: SubscriptionContext,
97-
publication: PublishIfDocument<PublishDocType<K>>,
98-
...args: Parameters<AllPubSubTypes[K]>
99-
) => Promise<void>
100-
): void {
101-
meteorPublishUnsafe(publicationName, async function (this: SubscriptionContext, ...args: any[]) {
102-
return cb.call(this, new CustomPublishMeteor<any>(this, String(customCollectionName)) as any, ...(args as any))
103-
})
104-
}
88+
export type PublishIfDocument<Doc> = Doc extends { _id: ProtectedString<any> } ? CustomPublish<Doc> : never

meteor/server/main.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,22 +51,28 @@ import './logo'
5151
import './systemTime'
5252
// import './performanceMonitor' // called above
5353

54-
// Setup publications and security:
55-
import './publications/_publications'
56-
import './security/securityVerify'
57-
5854
import { MethodRegistry } from './methodRegistry'
5955
import { registerAllApiMethods } from './methodRegistrations'
56+
import { PublicationRegistry } from './publicationRegistry'
57+
import { registerAllPublications } from './publicationRegistrations'
6058
import { bindRestApiRouter } from './api/rest/api'
6159
import { startupVerifyAllMethods } from './security/securityVerify'
6260

6361
// Build and populate the method registry
6462
const methodRegistry = new MethodRegistry()
6563
registerAllApiMethods(methodRegistry)
6664

67-
// Apply methods
65+
// Build and populate the publication registry
66+
const publicationRegistry = new PublicationRegistry()
67+
registerAllPublications(publicationRegistry)
68+
69+
// Apply methods and publications
6870
methodRegistry.applyToMeteor()
71+
publicationRegistry.applyToMeteor()
6972
Meteor.startup(() => {
70-
bindRestApiRouter(methodRegistry)
73+
bindRestApiRouter(methodRegistry, publicationRegistry)
7174
startupVerifyAllMethods(methodRegistry)
75+
76+
// Ensure all the publications were registered at startup
77+
if (Meteor.isDevelopment) publicationRegistry.verifyAllPublicationsRegistered()
7278
})
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { PublicationRegistry } from './publicationRegistry'
2+
3+
import { registerBucketsPublications } from './publications/buckets'
4+
import { registerBlueprintUpgradeStatusPublications } from './publications/blueprintUpgradeStatus/publication'
5+
import { registerIngestStatusPublications } from './publications/ingestStatus/publication'
6+
import { registerExternalEventSubscriptionsPublications } from './publications/externalEventSubscriptions'
7+
import { registerExpectedPackagesPublications } from './publications/packageManager/expectedPackages/publication'
8+
import { registerPackageContainersPublications } from './publications/packageManager/packageContainers'
9+
import { registerPlayoutContextPublications } from './publications/packageManager/playoutContext'
10+
import { registerBucketContentStatusUIPublications } from './publications/pieceContentStatusUI/bucket/publication'
11+
import { registerRundownContentStatusUIPublications } from './publications/pieceContentStatusUI/rundown/publication'
12+
import { registerOrganizationPublications } from './publications/organization'
13+
import { registerPartsUIPublications } from './publications/partsUI/publication'
14+
import { registerPartInstancesUIPublications } from './publications/partInstancesUI/publication'
15+
import { registerPeripheralDevicePublications } from './publications/peripheralDevice'
16+
import { registerPeripheralDeviceForDevicePublications } from './publications/peripheralDeviceForDevice'
17+
import { registerRundownPublications } from './publications/rundown'
18+
import { registerRundownPlaylistPublications } from './publications/rundownPlaylist'
19+
import { registerSegmentPartNotesUIPublications } from './publications/segmentPartNotesUI/publication'
20+
import { registerShowStylePublications } from './publications/showStyle'
21+
import { registerShowStyleUIPublications } from './publications/showStyleUI'
22+
import { registerStudioPublications } from './publications/studio'
23+
import { registerStudioUIPublications } from './publications/studioUI'
24+
import { registerSystemPublications } from './publications/system'
25+
import { registerTimelinePublications } from './publications/timeline'
26+
import { registerTranslationsBundlesPublications } from './publications/translationsBundles'
27+
import { registerTriggeredActionsUIPublications } from './publications/triggeredActionsUI'
28+
import { registerMountedTriggersPublications } from './publications/mountedTriggers'
29+
import { registerDeviceTriggersPreviewPublications } from './publications/deviceTriggersPreview'
30+
31+
/** Register every DDP publication on the given registry. */
32+
export function registerAllPublications(registry: PublicationRegistry): void {
33+
registerBucketsPublications(registry)
34+
registerBlueprintUpgradeStatusPublications(registry)
35+
registerIngestStatusPublications(registry)
36+
registerExternalEventSubscriptionsPublications(registry)
37+
registerExpectedPackagesPublications(registry)
38+
registerPackageContainersPublications(registry)
39+
registerPlayoutContextPublications(registry)
40+
registerBucketContentStatusUIPublications(registry)
41+
registerRundownContentStatusUIPublications(registry)
42+
registerOrganizationPublications(registry)
43+
registerPartsUIPublications(registry)
44+
registerPartInstancesUIPublications(registry)
45+
registerPeripheralDevicePublications(registry)
46+
registerPeripheralDeviceForDevicePublications(registry)
47+
registerRundownPublications(registry)
48+
registerRundownPlaylistPublications(registry)
49+
registerSegmentPartNotesUIPublications(registry)
50+
registerShowStylePublications(registry)
51+
registerShowStyleUIPublications(registry)
52+
registerStudioPublications(registry)
53+
registerStudioUIPublications(registry)
54+
registerSystemPublications(registry)
55+
registerTimelinePublications(registry)
56+
registerTranslationsBundlesPublications(registry)
57+
registerTriggeredActionsUIPublications(registry)
58+
registerMountedTriggersPublications(registry)
59+
registerDeviceTriggersPreviewPublications(registry)
60+
}

0 commit comments

Comments
 (0)