Skip to content

Commit c97d301

Browse files
committed
fix: ensure that a page update won't propagate to its database node
Previously, contentDigest is computed together with a node's children including its content and id. Therefore, if a child get updated, its parent node will get an unexpected updated with a new digestContent, triggering the old parent node getting removed together with its children, i.e. the updated child's siblings. This update will address this issue by excluding the child's content but their ids into the computation of digestContent.
1 parent 2c7e4ff commit c97d301

File tree

2 files changed

+317
-129
lines changed

2 files changed

+317
-129
lines changed

source/node.ts

Lines changed: 118 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ type NormalisedEntity<E extends FullEntity = FullEntity> = E extends any
3838
? Omit<E, 'parent'> & {
3939
parent: Link | null;
4040
children: Link[];
41-
digest: string;
4241
}
4342
: never;
4443

@@ -50,6 +49,7 @@ export class NodeManager {
5049
private createNodeId: NodePluginArgs['createNodeId'];
5150
private createContentDigest: NodePluginArgs['createContentDigest'];
5251
private cache: NodePluginArgs['cache'];
52+
private getNode: NodePluginArgs['getNode'];
5353
private reporter: NodePluginArgs['reporter'];
5454

5555
/**
@@ -63,6 +63,7 @@ export class NodeManager {
6363
cache,
6464
createContentDigest,
6565
createNodeId,
66+
getNode,
6667
reporter,
6768
} = args;
6869
/* eslint-enable */
@@ -73,6 +74,7 @@ export class NodeManager {
7374
this.touchNode = touchNode;
7475
this.createNodeId = createNodeId;
7576
this.createContentDigest = createContentDigest;
77+
this.getNode = getNode;
7678
this.reporter = reporter;
7779
}
7880

@@ -82,29 +84,28 @@ export class NodeManager {
8284
*/
8385
public async update(entities: FullEntity[]): Promise<void> {
8486
// get entries with relationship build-in
85-
const oldMap = new Map<string, NormalisedEntity>(
86-
(await this.cache.get('entityMap')) ?? [],
87+
const old = new Map<string, NodeInput>(
88+
(await this.cache.get('nodeGraph')) ?? [],
8789
);
88-
const newMap = computeEntityMap(entities, this.createContentDigest);
90+
const current = this.computeNodeGraph(entities);
91+
const { added, updated, removed, unchanged } = computeChanges(old, current);
8992

9093
// for the usage of createNode
9194
// see https://www.gatsbyjs.com/docs/reference/config-files/actions/#createNode
92-
await this.addNodes(this.findNewEntities(oldMap, newMap));
93-
this.updateNodes(this.findUpdatedEntities(oldMap, newMap));
94-
this.removeNodes(this.findRemovedEntities(oldMap, newMap));
95-
this.touchNodes([...newMap.values()]);
95+
await this.addNodes(added);
96+
await this.updateNodes(updated);
97+
this.removeNodes(removed);
98+
this.touchNodes(unchanged);
9699

97-
await this.cache.set('entityMap', [...newMap.entries()]);
100+
await this.cache.set('nodeGraph', [...current.entries()]);
98101
}
99102

100103
/**
101104
* add new nodes
102105
* @param added new nodes to be added
103106
*/
104-
private async addNodes(added: NormalisedEntity[]): Promise<void> {
105-
for (const entity of added) {
106-
const node = this.nodifyEntity(entity);
107-
107+
private async addNodes(added: NodeInput[]): Promise<void> {
108+
for (const node of added) {
108109
// DEBT: disable a false alarm from eslint as currently Gatsby is exporting an incorrect type
109110
// this should be removed when https://github.com/gatsbyjs/gatsby/pull/32522 is merged
110111
/* eslint-disable @typescript-eslint/await-thenable */
@@ -123,9 +124,14 @@ export class NodeManager {
123124
* update existing nodes
124125
* @param updated updated nodes
125126
*/
126-
private updateNodes(updated: NormalisedEntity[]): void {
127-
for (const entity of updated) {
128-
this.createNode(this.nodifyEntity(entity));
127+
private async updateNodes(updated: NodeInput[]): Promise<void> {
128+
for (const node of updated) {
129+
// DEBT: disable a false alarm from eslint as currently Gatsby is exporting an incorrect type
130+
// this should be removed when https://github.com/gatsbyjs/gatsby/pull/32522 is merged
131+
/* eslint-disable @typescript-eslint/await-thenable */
132+
// update the node
133+
await this.createNode(node);
134+
/* eslint-enable */
129135
}
130136

131137
// don't be noisy if there's nothing new happen
@@ -138,9 +144,9 @@ export class NodeManager {
138144
* remove old nodes
139145
* @param removed nodes to be removed
140146
*/
141-
private removeNodes(removed: NormalisedEntity[]): void {
142-
for (const entity of removed) {
143-
this.deleteNode(this.nodifyEntity(entity));
147+
private removeNodes(removed: NodeInput[]): void {
148+
for (const node of removed) {
149+
this.deleteNode(node);
144150
}
145151

146152
// don't be noisy if there's nothing new happen
@@ -150,22 +156,47 @@ export class NodeManager {
150156
}
151157

152158
/**
153-
* keep all current notion nodes alive
154-
* @param entities list of current notion entities
159+
* keep unchanged notion nodes alive
160+
* @param untouched list of current notion entities
155161
*/
156-
private touchNodes(entities: NormalisedEntity[]): void {
157-
for (const entity of entities) {
158-
const node = this.nodifyEntity(entity);
159-
this.touchNode({
160-
id: node.id,
161-
internal: {
162-
type: node.internal.type,
163-
contentDigest: node.internal.contentDigest,
164-
},
165-
});
162+
private touchNodes(untouched: NodeInput[]): void {
163+
for (const node of untouched) {
164+
// DEBT: disable a false alarm from eslint as currently Gatsby is exporting an incorrect type
165+
// this should be removed when https://github.com/gatsbyjs/gatsby/pull/32522 is merged
166+
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
167+
if (this.getNode(node.id)) {
168+
// just make a light-touched operation if the node is still alive
169+
this.touchNode({
170+
id: node.id,
171+
internal: {
172+
type: node.internal.type,
173+
contentDigest: node.internal.contentDigest,
174+
},
175+
});
176+
} else {
177+
// recreate it again if somehow it's missing
178+
this.createNode(node);
179+
}
166180
}
167181

168-
this.reporter.info(`[${name}] processed ${entities.length} nodes`);
182+
this.reporter.info(`[${name}] keeping ${untouched.length} nodes`);
183+
}
184+
185+
/**
186+
* convert entities into gatsby node with full parent-child relationship
187+
* @param entities all sort of entities including database and page
188+
* @returns a map of gatsby nodes with parent and children linked
189+
*/
190+
private computeNodeGraph(entities: FullEntity[]): Map<string, NodeInput> {
191+
// first compute the graph with entities before converting to nodes
192+
const entityMap = computeEntityMap(entities);
193+
194+
return new Map<string, NodeInput>(
195+
[...entityMap.entries()].map(([id, entity]) => [
196+
id,
197+
this.nodifyEntity(entity),
198+
]),
199+
);
169200
}
170201

171202
/**
@@ -204,7 +235,7 @@ export class NodeManager {
204235
entity: NormalisedEntity,
205236
internal: Omit<NodeInput['internal'], 'contentDigest'> & { type: T },
206237
): ContentNode<T> {
207-
return {
238+
const basis = {
208239
id: this.createNodeId(`${entity.object}:${entity.id}`),
209240
ref: entity.id,
210241
createdTime: entity.created_time,
@@ -217,77 +248,20 @@ export class NodeManager {
217248
children: entity.children.map(({ object, id }) =>
218249
this.createNodeId(`${object}:${id}`),
219250
),
251+
};
252+
253+
const excludedKeys = ['parent', 'children', 'internal'];
254+
const contentDigest = this.createContentDigest(omit(basis, excludedKeys));
255+
256+
return {
257+
...basis,
220258
internal: {
221-
contentDigest: entity.digest,
259+
contentDigest,
222260
...internal,
223261
},
224262
};
225263
}
226264

227-
/**
228-
* find new entities
229-
* @param oldMap the old entity map generated from earlier data
230-
* @param newMap the new entity map computed from up-to-date data from Notion
231-
* @returns a list of new entities
232-
*/
233-
private findNewEntities(
234-
oldMap: Map<string, NormalisedEntity>,
235-
newMap: Map<string, NormalisedEntity>,
236-
): NormalisedEntity[] {
237-
const added: NormalisedEntity[] = [];
238-
for (const [id, newEntity] of newMap.entries()) {
239-
const oldEntity = oldMap.get(id);
240-
if (!oldEntity) {
241-
added.push(newEntity);
242-
}
243-
}
244-
245-
return added;
246-
}
247-
248-
/**
249-
* find removed entities
250-
* @param oldMap the old entity map generated from earlier data
251-
* @param newMap the new entity map computed from up-to-date data from Notion
252-
* @returns a list of removed entities
253-
*/
254-
private findRemovedEntities(
255-
oldMap: Map<string, NormalisedEntity>,
256-
newMap: Map<string, NormalisedEntity>,
257-
): NormalisedEntity[] {
258-
const removed: NormalisedEntity[] = [];
259-
260-
for (const [id, oldEntity] of oldMap.entries()) {
261-
if (!newMap.has(id)) {
262-
removed.push(oldEntity);
263-
}
264-
}
265-
266-
return removed;
267-
}
268-
269-
/**
270-
* find updated entities
271-
* @param oldMap the old entity map generated from earlier data
272-
* @param newMap the new entity map computed from up-to-date data from Notion
273-
* @returns a list of updated entities
274-
*/
275-
private findUpdatedEntities(
276-
oldMap: Map<string, NormalisedEntity>,
277-
newMap: Map<string, NormalisedEntity>,
278-
): NormalisedEntity[] {
279-
const updated: NormalisedEntity[] = [];
280-
281-
for (const [id, newEntity] of newMap.entries()) {
282-
const oldEntity = oldMap.get(id);
283-
if (oldEntity && oldEntity.digest !== newEntity.digest) {
284-
updated.push(newEntity);
285-
}
286-
}
287-
288-
return updated;
289-
}
290-
291265
/**
292266
* convert an entity to a NodeInput
293267
* @param entity the entity to be converted
@@ -306,15 +280,44 @@ export class NodeManager {
306280
}
307281
}
308282

283+
/**
284+
* compute changes between two node graphs
285+
* @param old the old graph
286+
* @param current the latest graph
287+
* @returns a map of nodes in different states
288+
*/
289+
export function computeChanges(
290+
old: Map<string, NodeInput>,
291+
current: Map<string, NodeInput>,
292+
): Record<'added' | 'updated' | 'removed' | 'unchanged', NodeInput[]> {
293+
const added = [...current.entries()].filter(([id]) => !old.has(id));
294+
const removed = [...old.entries()].filter(([id]) => !current.has(id));
295+
296+
const bothExists = [...current.entries()].filter(([id]) => old.has(id));
297+
const updated = bothExists.filter(
298+
([id, node]) =>
299+
old.get(id)!.internal.contentDigest !== node.internal.contentDigest,
300+
);
301+
const unchanged = bothExists.filter(
302+
([id, node]) =>
303+
old.get(id)!.internal.contentDigest === node.internal.contentDigest,
304+
);
305+
306+
return {
307+
added: added.map(([, node]) => node),
308+
updated: updated.map(([, node]) => node),
309+
removed: removed.map(([, node]) => node),
310+
unchanged: unchanged.map(([, node]) => node),
311+
};
312+
}
313+
309314
/**
310315
* attach parent-child relationship to gatsby node
311316
* @param entities all sort of entities including database and page
312-
* @param hashFn a hash function for generating the content digest
313317
* @returns a map of entities with parent and children linked
314318
*/
315319
export function computeEntityMap(
316320
entities: FullEntity[],
317-
hashFn: (content: string | FullEntity) => string,
318321
): Map<string, NormalisedEntity> {
319322
// create a new working set
320323
const map = new Map<string, NormalisedEntity>();
@@ -323,7 +326,6 @@ export function computeEntityMap(
323326
...entity,
324327
parent: normaliseParent(entity.parent),
325328
children: [],
326-
digest: hashFn(entity),
327329
});
328330
}
329331

@@ -366,3 +368,18 @@ export function normaliseParent(parent: FullEntity['parent']): Link | null {
366368
throw new TypeError(`unknown parent`);
367369
}
368370
}
371+
372+
/**
373+
* return an object with the specified keys omitted
374+
* @param record the record to be converted
375+
* @param keys a list of keys to be omitted
376+
* @returns an object with the specified keys omitted
377+
*/
378+
function omit(
379+
record: Record<string, unknown>,
380+
keys: string[],
381+
): Record<string, unknown> {
382+
return Object.fromEntries(
383+
Object.entries(record).filter(([key]) => !keys.includes(key)),
384+
);
385+
}

0 commit comments

Comments
 (0)