Skip to content

Commit aeafb16

Browse files
committed
Add pg migration to track approved changes; ui; resolvers and storage
1 parent 40fd27d commit aeafb16

30 files changed

Lines changed: 1022 additions & 143 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { type MigrationExecutor } from '../pg-migrator';
2+
3+
export default {
4+
name: '2026.03.28T00-00-00.proposal-change-tracking.ts',
5+
run: ({ psql }) => psql`
6+
CREATE TABLE IF NOT EXISTS "proposal_approved_changes" (
7+
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4 (),
8+
9+
-- hash of the metadata. Because this is a hash, it has a slim
10+
-- chance of conflicting. This is why we need the "change" to perform a more
11+
-- exact match using the "@graphql-inspector/compare-changes" package
12+
"hash" TEXT NOT NULL,
13+
14+
-- this is the exact change
15+
"change" JSONB NOT NULL,
16+
17+
"proposal_id" UUID NOT NULL
18+
REFERENCES "schema_proposals"("id"),
19+
20+
-- This is the version in which the approved change has been implemented.
21+
-- It is for linking the change to the schema history
22+
"schema_version_id" UUID
23+
REFERENCES "schema_versions"("id"),
24+
25+
"service" TEXT,
26+
"target_id" UUID NOT NULL
27+
REFERENCES "targets"("id")
28+
ON DELETE CASCADE
29+
);
30+
31+
-- partial index used on publish to look up if a change is approved and not implemented
32+
CREATE INDEX "target_change_not_implemented" ON "proposal_approved_changes" ("target_id", "hash")
33+
WHERE "schema_version_id" IS NULL;
34+
35+
-- look up on a proposal, which changes have been implemented
36+
-- and where they were implemented
37+
CREATE INDEX "proposal_change_implemented" ON "proposal_approved_changes" ("target_id", "proposal_id")
38+
WHERE "schema_version_id" IS NOT NULL;
39+
40+
-- look up on the schema history page, which proposal the change belonged to
41+
CREATE INDEX "schema_implements_changes" ON "proposal_approved_changes" ("target_id", "schema_version_id");
42+
`,
43+
} satisfies MigrationExecutor;

packages/migrations/src/run-pg-migrations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,5 +184,6 @@ export const runPGMigrations = async (args: { slonik: PostgresDatabasePool; runT
184184
await import('./actions/2026.02.24T00-00-00.proposal-composition'),
185185
await import('./actions/2026.02.25T00-00-00.oidc-integration-domains'),
186186
await import('./actions/2026.03.25T00-00-00.access-token-expiration'),
187+
await import('./actions/2026.03.28T00-00-00.proposal-change-tracking'),
187188
],
188189
});

packages/services/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"@date-fns/utc": "2.1.1",
2424
"@graphql-hive/core": "workspace:*",
2525
"@graphql-hive/signal": "1.0.0",
26+
"@graphql-inspector/compare-changes": "0.1.0-alpha-20260328000712-2cf98744a012fa66614588ed89ba99719b0f461b",
2627
"@graphql-inspector/core": "7.1.2",
2728
"@graphql-tools/merge": "9.1.1",
2829
"@hive/cdn-script": "workspace:*",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export type SchemaProposalChangeDetailsMapper =
2+
| {
3+
schemaProposal: { id: string };
4+
implementedBy?: { id: string } | null;
5+
}
6+
| null
7+
| undefined;

packages/services/api/src/modules/proposals/module.graphql.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -331,12 +331,6 @@ export default gql`
331331
"""
332332
serviceName: String!
333333
334-
# @todo
335-
# """
336-
# The specific version of the proposal that this review is for.
337-
# """
338-
# schemaProposalVersion: SchemaProposalVersion
339-
340334
"""
341335
If null then this review is just a comment. Otherwise, the reviewer changed the state of the
342336
proposal as part of their review. E.g. The reviewer can approve a version with a comment.
@@ -375,6 +369,26 @@ export default gql`
375369
376370
extend type SchemaChange {
377371
meta: SchemaChangeMeta
372+
373+
"""
374+
If this change was proposed by a schema proposal, then this returns
375+
details about that proposal and where the change was implemented.
376+
"""
377+
schemaProposalChangeDetails: SchemaProposalChangeDetails
378+
}
379+
380+
type SchemaProposalChangeDetails {
381+
"""
382+
If this schema change is associated with a proposal, this returns that proposal
383+
This is useful for matching changes from a schema check or schema history/version to a proposal.
384+
"""
385+
schemaProposal: SchemaProposal!
386+
387+
"""
388+
If this change is for a schema proposal, this is the schema version
389+
(viewable in the History) where the change was implemented.
390+
"""
391+
implementedBy: SchemaVersion
378392
}
379393
380394
union SchemaChangeMeta =

packages/services/api/src/modules/proposals/providers/schema-proposal-manager.ts

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,38 @@
11
/**
22
* This wraps the higher level logic with schema proposals.
33
*/
4+
import { SchemaProposalChangeDetailsMapper } from '../module.graphql.mappers';
5+
import DataLoader from 'dataloader';
46
import { Inject, Injectable, Scope } from 'graphql-modules';
5-
import { TargetReferenceInput } from 'packages/libraries/core/src/client/__generated__/types';
7+
import type { TargetReferenceInput } from 'packages/libraries/core/src/client/__generated__/types';
8+
import { isChangeEqual } from '@graphql-inspector/compare-changes';
9+
import { Change } from '@graphql-inspector/core';
10+
import { traceFn } from '@hive/service-common';
11+
import { SchemaChangeType } from '@hive/storage';
612
import type { SchemaProposalStage } from '../../../__generated__/types';
713
import { HiveError } from '../../../shared/errors';
14+
import { cache } from '../../../shared/helpers';
815
import { Session } from '../../auth/lib/authz';
916
import { IdTranslator } from '../../shared/providers/id-translator';
1017
import { Logger } from '../../shared/providers/logger';
1118
import { PUB_SUB_CONFIG, type HivePubSub } from '../../shared/providers/pub-sub';
1219
import { Storage } from '../../shared/providers/storage';
1320
import { SchemaProposalStorage } from './schema-proposal-storage';
1421

22+
type ChangeSelector = {
23+
targetId: string;
24+
change: SchemaChangeType;
25+
};
26+
1527
@Injectable({
1628
scope: Scope.Operation,
1729
})
1830
export class SchemaProposalManager {
1931
private logger: Logger;
32+
private approvedChangeProposalLoader: DataLoader<
33+
ChangeSelector,
34+
SchemaProposalChangeDetailsMapper
35+
>;
2036

2137
constructor(
2238
logger: Logger,
@@ -27,6 +43,69 @@ export class SchemaProposalManager {
2743
@Inject(PUB_SUB_CONFIG) private pubSub: HivePubSub,
2844
) {
2945
this.logger = logger.child({ source: 'SchemaProposalsManager' });
46+
47+
const cacheKeyFn = function (selector: ChangeSelector) {
48+
return `${selector.targetId}/${selector.change.id}`;
49+
};
50+
this.approvedChangeProposalLoader = new DataLoader(
51+
async selectors => {
52+
// map the list of keys in the order in which they are passed in so it can
53+
// be mapped back to.
54+
const keys = selectors.map(cacheKeyFn);
55+
56+
// combine selectors by targetId
57+
const groupedSelectors = selectors.reduce((groups, selector) => {
58+
const group = groups.get(selector.targetId);
59+
if (!group) {
60+
groups.set(selector.targetId, [selector.change]);
61+
} else {
62+
group.push(selector.change);
63+
}
64+
return groups;
65+
}, new Map<string, SchemaChangeType[]>());
66+
67+
// fetch the groups
68+
const approvedProposedChangesByCacheId = await Promise.all(
69+
groupedSelectors.entries().map(async ([targetId, changes]) => {
70+
const result = await this.proposalStorage.getEquivalentUnimplementedApprovedChanges({
71+
changes: changes as unknown as Change<any>[],
72+
targetId,
73+
});
74+
const entries = result
75+
.map((apc, i) => {
76+
if (apc) {
77+
return [cacheKeyFn({ change: changes[i], targetId }), apc] as const;
78+
}
79+
})
80+
.filter(e => e !== undefined);
81+
return Object.fromEntries(entries);
82+
}),
83+
);
84+
85+
// map back to the original selectors
86+
return keys.map((cacheKey): SchemaProposalChangeDetailsMapper => {
87+
for (const targetChangesByCacheId of approvedProposedChangesByCacheId) {
88+
const approvedRecord = targetChangesByCacheId[cacheKey];
89+
if (approvedRecord) {
90+
return {
91+
schemaProposal: {
92+
id: approvedRecord.proposalId,
93+
},
94+
implementedBy: approvedRecord.schemaVersionId
95+
? {
96+
id: approvedRecord.schemaVersionId,
97+
}
98+
: null,
99+
};
100+
}
101+
}
102+
return null;
103+
});
104+
},
105+
{
106+
cacheKeyFn,
107+
},
108+
);
30109
}
31110

32111
async subscribeToSchemaProposalCompositions(args: { proposalId: string }) {
@@ -116,6 +195,7 @@ export class SchemaProposalManager {
116195
};
117196
}
118197

198+
@cache<{ id: string }>(({ id }) => id)
119199
async getProposal(args: { id: string }) {
120200
const proposal = await this.proposalStorage.getProposal(args);
121201

@@ -255,4 +335,90 @@ export class SchemaProposalManager {
255335

256336
throw new HiveError('Not implemented');
257337
}
338+
339+
@traceFn('SchemaProposalManager.getChangeDetailsForCheck', {
340+
initAttributes: input => ({
341+
'hive.schema_proposal.id': input.schemaProposalId || '',
342+
'hive.target.id': input.targetId,
343+
}),
344+
resultAttributes: result => ({
345+
'hive.proposals.details.length': result?.length || 0,
346+
}),
347+
})
348+
@cache<{
349+
schemaProposalId: string;
350+
targetId: string;
351+
}>(({ schemaProposalId, targetId }) => `${targetId}/${schemaProposalId}`)
352+
async _getImplementedVersionsByProposalId(args: { schemaProposalId: string; targetId: string }) {
353+
return this.proposalStorage.getImplementedVersionsByProposalId({
354+
schemaProposalId: args.schemaProposalId,
355+
targetId: args.targetId,
356+
});
357+
}
358+
359+
/**
360+
* If dealing with a non-schema proposal related check or schema version,
361+
* then this function can search to find if any of the check's or historic changes
362+
* are matches for approved schema proposal changes.
363+
*
364+
* Note that if you need the ChangeDetails object for a schema proposal's changes, then use
365+
* `getProposalChangeDetails`.
366+
**/
367+
async getMatchingApprovedProposalChangeDetails({
368+
targetId,
369+
change,
370+
}: {
371+
targetId: string;
372+
change: SchemaChangeType;
373+
}) {
374+
return this.approvedChangeProposalLoader.load({ targetId, change });
375+
}
376+
377+
/**
378+
* Schema proposals use checks under the hood which makes this a bit confusing.
379+
* This function is for finding `proposalChangeDetails` for a schema proposal's
380+
* associated check's changes -- effectively the proposed changes.
381+
*/
382+
async getProposalChangeDetails({
383+
schemaProposalId,
384+
targetId,
385+
change,
386+
}: {
387+
targetId: string;
388+
389+
// The ID of the schema proposal that is proposing this change
390+
schemaProposalId: string;
391+
392+
// The change record. Used to compare against the returned implemented change
393+
// to verify that they are equal.
394+
change: SchemaChangeType;
395+
}) {
396+
// `_getImplementedVersionsByProposalId` is cached so it's safe to call multiple times.
397+
// This is intended to be used by the SchemaChange resolver and would be called on every
398+
// SchemaChange.
399+
const implementedChanges = await this._getImplementedVersionsByProposalId({
400+
schemaProposalId,
401+
targetId,
402+
});
403+
404+
// Verify which versions implement the associated changes using an exact compare
405+
const implementation = implementedChanges.find(
406+
implementedChange =>
407+
isChangeEqual(
408+
implementedChange.change as unknown as Change<any>,
409+
change as unknown as Change<any>,
410+
) && implementedChange.schemaVersionId,
411+
);
412+
if (implementation) {
413+
return {
414+
schemaProposal: { id: schemaProposalId },
415+
implementedBy: implementation?.schemaVersionId
416+
? {
417+
id: implementation.schemaVersionId,
418+
}
419+
: null,
420+
};
421+
}
422+
return null;
423+
}
258424
}

0 commit comments

Comments
 (0)