@jungvonmatt/contentful-ssg 1.4.9 → 1.7.3

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/README.md CHANGED
@@ -209,6 +209,33 @@ Get values from linked pages to e.g. build an url out of parent slugs.
209
209
 
210
210
  The same as collectValues just without the value from the current entry
211
211
 
212
+ ###### waitFor
213
+
214
+ Wait for specific entry to be transformed.
215
+ Be aware that this can lead to deadlocks when you're awaiting something which
216
+ itself is waiting for the current entry to be transformed.
217
+
218
+ ```js
219
+ {
220
+ transform: (context) => {
221
+ const { utils } = context;
222
+ try {
223
+ // You can overwrite the default wait timeout of 20000ms using the second parameter
224
+ const linkedContext = await utils.waitFor('<contentful-id>', 5000);
225
+
226
+ // Do something usefull with the transformed data
227
+ // which you can't do with context.entryMap.get('<contentful-id>')
228
+
229
+ } catch (error) {
230
+ // Entry isn't available, the transform method for the entry throws an error
231
+ // or we encountered a cyclic dependency
232
+ }
233
+
234
+ return { ...content };
235
+ };
236
+ }
237
+ ```
238
+
212
239
  ### fetch
213
240
 
214
241
  Fetch all content entries and store them as yaml in the configured directory
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import Listr from 'listr';
2
+ import { ReplaySubject } from 'rxjs';
2
3
  import chalk from 'chalk';
3
4
  import { getContentTypeId, getContentId } from './lib/contentful.js';
4
5
  import { setup } from './tasks/setup.js';
@@ -6,7 +7,7 @@ import { fetch } from './tasks/fetch.js';
6
7
  import { localize } from './tasks/localize.js';
7
8
  import { transform } from './tasks/transform.js';
8
9
  import { write } from './tasks/write.js';
9
- import { collectParentValues, collectValues } from './lib/utils.js';
10
+ import { collectParentValues, collectValues, waitFor } from './lib/utils.js';
10
11
  import { ValidationError } from './lib/error.js';
11
12
  class CustomListrRenderer {
12
13
  _tasks;
@@ -63,6 +64,8 @@ export const run = async (config) => {
63
64
  const tasks = locales.map((locale) => ({
64
65
  title: `${locale.code}`,
65
66
  task: async () => {
67
+ const subject = new ReplaySubject();
68
+ const observable = subject.asObservable();
66
69
  const data = ctx.localized.get(locale.code);
67
70
  const { entries = [] } = data || {};
68
71
  const promises = entries.map(async (entry) => {
@@ -71,6 +74,7 @@ export const run = async (config) => {
71
74
  const utils = {
72
75
  collectValues: collectValues({ ...data, entry }),
73
76
  collectParentValues: collectParentValues({ ...data, entry }),
77
+ waitFor: waitFor({ ...data, entry, observable }),
74
78
  };
75
79
  const transformContext = {
76
80
  ...data,
@@ -79,9 +83,11 @@ export const run = async (config) => {
79
83
  entry,
80
84
  locale,
81
85
  utils,
86
+ observable,
82
87
  };
83
88
  try {
84
89
  const content = await transform(transformContext, ctx, config);
90
+ subject.next({ ...transformContext, content });
85
91
  if (typeof content === 'undefined') {
86
92
  return;
87
93
  }
@@ -89,10 +95,16 @@ export const run = async (config) => {
89
95
  ctx.stats.addSuccess(transformContext);
90
96
  }
91
97
  catch (error) {
98
+ if (error instanceof Error) {
99
+ subject.next({ ...transformContext, error });
100
+ }
101
+ else {
102
+ subject.next({ ...transformContext, error: new Error(`${error}`) });
103
+ }
92
104
  if (error instanceof ValidationError) {
93
105
  ctx.stats.addSkipped(transformContext, error);
94
106
  }
95
- else if (typeof error === 'string' || error instanceof Error) {
107
+ else {
96
108
  ctx.stats.addError(transformContext, error);
97
109
  }
98
110
  }
@@ -7,3 +7,7 @@ export declare class ValidationError extends Error {
7
7
  locale: string;
8
8
  constructor(entry: ErrorEntry);
9
9
  }
10
+ export declare class WrappedError extends Error {
11
+ originalError: unknown;
12
+ constructor(message: string, error: unknown);
13
+ }
package/dist/lib/error.js CHANGED
@@ -15,3 +15,11 @@ export class ValidationError extends Error {
15
15
  this.name = 'ValidationError';
16
16
  }
17
17
  }
18
+ export class WrappedError extends Error {
19
+ originalError;
20
+ constructor(message, error) {
21
+ super(message);
22
+ this.originalError = error;
23
+ this.name = 'WrappedError';
24
+ }
25
+ }
@@ -12,7 +12,7 @@ export declare class Stats {
12
12
  contentTypeId: string;
13
13
  };
14
14
  addSuccess(context: TransformContext, message?: string): void;
15
- addError(context: TransformContext, error: string | Error): void;
15
+ addError(context: TransformContext, error: unknown): void;
16
16
  addSkipped(context: TransformContext, error: ValidationError): void;
17
17
  print(): Promise<void>;
18
18
  }
package/dist/lib/stats.js CHANGED
@@ -21,11 +21,11 @@ export class Stats {
21
21
  this.success.push({ message, ...this.toEntry(context) });
22
22
  }
23
23
  addError(context, error) {
24
- if (typeof error === 'string') {
25
- this.errors.push({ error: new Error(error), ...this.toEntry(context) });
24
+ if (error instanceof Error) {
25
+ this.errors.push({ error, ...this.toEntry(context) });
26
26
  }
27
27
  else {
28
- this.errors.push({ error, ...this.toEntry(context) });
28
+ this.errors.push({ error: new Error(`${error}`), ...this.toEntry(context) });
29
29
  }
30
30
  }
31
31
  addSkipped(context, error) {
@@ -2,3 +2,4 @@ import type { CollectOptions, Entry, TransformContext } from '../types.js';
2
2
  export declare const collectValues: (transformContext: Pick<TransformContext, 'entry' | 'entryMap'>) => (key: any, options?: CollectOptions) => any[];
3
3
  export declare const collectParentValues: (transformContext: Pick<TransformContext, 'entry' | 'entryMap'>) => (key: any, options?: CollectOptions) => any[];
4
4
  export declare const collect: <T = unknown>(entry: Entry, entryMap: Map<string, Entry>, options: CollectOptions) => T[];
5
+ export declare const waitFor: (transformContext: Pick<TransformContext, 'entry' | 'observable' | 'entryMap'>) => (id: string, waitTimeout?: number) => Promise<unknown>;
package/dist/lib/utils.js CHANGED
@@ -1,4 +1,8 @@
1
+ import { getContentId, getContentTypeId } from './contentful.js';
2
+ import { ReplaySubject, map, distinct, filter } from 'rxjs';
1
3
  import dlv from 'dlv';
4
+ import { WrappedError } from './error.js';
5
+ const DEFAULT_WAIT_TIMEOUT = 20000;
2
6
  export const collectValues = (transformContext) => (key, options) => {
3
7
  const { entry: defaultEntry, entryMap: defaultEntryMap } = transformContext;
4
8
  const { getNextId, linkField, entry = defaultEntry, entryMap = defaultEntryMap, } = options || {};
@@ -29,3 +33,73 @@ export const collect = (entry, entryMap, options) => {
29
33
  }
30
34
  return [value];
31
35
  };
36
+ const dependencies = {};
37
+ const buildDependencies = (v) => {
38
+ if ((dependencies?.[v.source] ?? []).includes(v.dest)) {
39
+ return dependencies;
40
+ }
41
+ if (dependencies[v.source]) {
42
+ dependencies[v.source] = [...dependencies[v.source], v.dest];
43
+ }
44
+ else {
45
+ dependencies[v.source] = [v.dest];
46
+ }
47
+ Object.entries({ ...dependencies })
48
+ .filter(([, value]) => value.includes(v.source))
49
+ .forEach(([key, value]) => {
50
+ dependencies[key] = [...new Set([...value, v.dest, ...(dependencies[v.dest] || [])])];
51
+ });
52
+ return dependencies;
53
+ };
54
+ const waitForSubject = new ReplaySubject();
55
+ const cyclicErrorObservable = waitForSubject
56
+ .asObservable()
57
+ .pipe(map((value) => buildDependencies(value)));
58
+ export const waitFor = (transformContext) => {
59
+ const sourceEntry = transformContext.entry;
60
+ const sourceId = getContentId(sourceEntry);
61
+ const sourceContentTypeId = getContentTypeId(sourceEntry);
62
+ const source = `${sourceId} (${sourceContentTypeId})`;
63
+ return async (id, waitTimeout = DEFAULT_WAIT_TIMEOUT) => {
64
+ if (sourceId === id) {
65
+ throw new Error(`Can't wait for yourself.
66
+ Entry ${source} waiting for ${source}.`);
67
+ }
68
+ waitForSubject.next({ source: getContentId(sourceEntry), dest: id });
69
+ const destEntry = transformContext.entryMap.get(id);
70
+ const destId = getContentId(destEntry);
71
+ const destContentTypeId = getContentTypeId(destEntry);
72
+ const dest = `${destId} (${destContentTypeId})`;
73
+ return new Promise((resolve, reject) => {
74
+ const timeout = setTimeout(() => {
75
+ reject(new Error(`Exceeded timeout of ${waitTimeout} ms while waiting for entry ${id} to complete.
76
+ Entry ${source} waiting for ${dest}.`));
77
+ }, waitTimeout);
78
+ if (transformContext.entryMap.has(id)) {
79
+ transformContext.observable
80
+ .pipe(filter((ctx) => ctx?.entry?.sys?.id === id))
81
+ .subscribe((value) => {
82
+ clearTimeout(timeout);
83
+ if (value.error) {
84
+ reject(new WrappedError(`Awaited entry ${dest} errored`, value.error));
85
+ }
86
+ else {
87
+ resolve(value);
88
+ }
89
+ });
90
+ cyclicErrorObservable
91
+ .pipe(filter((v) => v?.[sourceId]?.includes(sourceId)))
92
+ .pipe(distinct())
93
+ .subscribe((v) => {
94
+ clearTimeout(timeout);
95
+ const deps = v?.[sourceId] ?? [];
96
+ reject(new Error(`Found cyclic dependency in ${source}: ${[sourceId, ...deps].join(' -> ')}`));
97
+ });
98
+ }
99
+ else {
100
+ clearTimeout(timeout);
101
+ reject(new Error(`No entry with id "${id}" available`));
102
+ }
103
+ });
104
+ };
105
+ };
package/dist/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Options } from '@contentful/rich-text-html-renderer';
2
2
  import type { Document } from '@contentful/rich-text-types';
3
+ import type { Observable } from 'rxjs';
3
4
  import type { QueryOptions, CollectionProp } from 'contentful-management/types';
4
5
  import type { EntryFields, Locale as ContentfulLocale, Asset as ContentfulAsset, Field, Entry as ContentfulEntry, ContentType as ContentfulContentType } from 'contentful';
5
6
  import type { ListrTaskObject } from 'listr';
@@ -131,6 +132,7 @@ export declare type Task = ListrTaskObject<RuntimeContext>;
131
132
  export interface TransformHelper {
132
133
  collectValues: <T>(key: any, options?: CollectOptions) => T[];
133
134
  collectParentValues: <T>(key: any, options?: CollectOptions) => T[];
135
+ waitFor: (id: string, waitTimeout?: number) => Promise<ObservableContext>;
134
136
  }
135
137
  export declare type TransformContext = LocalizedContent & {
136
138
  [x: string]: any;
@@ -145,7 +147,11 @@ export declare type TransformContext = LocalizedContent & {
145
147
  fieldSettings?: Field;
146
148
  requiredFields?: string[];
147
149
  utils: TransformHelper;
150
+ observable: Observable<ObservableContext>;
148
151
  };
152
+ export declare type ObservableContext = Readonly<Pick<TransformContext, 'id' | 'contentTypeId' | 'entry' | 'content' | 'locale'> & {
153
+ error?: Error;
154
+ }>;
149
155
  export interface Ignore {
150
156
  add(pattern: string | Ignore | string[] | Ignore[]): Ignore;
151
157
  filter(paths: string[]): string[];
package/package.json CHANGED
@@ -1,7 +1,14 @@
1
1
  {
2
2
  "name": "@jungvonmatt/contentful-ssg",
3
- "version": "1.4.9",
3
+ "version": "1.7.3",
4
4
  "description": "",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/jungvonmatt/contentful-ssg.git"
8
+ },
9
+ "bugs": {
10
+ "url": "https://github.com/jungvonmatt/contentful-ssg/issues"
11
+ },
5
12
  "main": "./dist/index.js",
6
13
  "type": "module",
7
14
  "typings": "./dist/types.d.ts",
@@ -118,6 +125,7 @@
118
125
  "micromatch": "^4.0.4",
119
126
  "prettier": "^2.4.1",
120
127
  "read-pkg-up": "^9.0.0",
128
+ "rxjs": "^7.5.5",
121
129
  "slash": "^4.0.0",
122
130
  "snake-case": "^3.0.4",
123
131
  "tempy": "^2.0.0",
@@ -155,5 +163,5 @@
155
163
  "module": "es2020"
156
164
  }
157
165
  },
158
- "gitHead": "17ccdcf1aedc0eea54740aa71279e5bd1cb77699"
166
+ "gitHead": "07c6810f528dc8cf51fad3f8e8cc6081facd9232"
159
167
  }
package/src/index.ts CHANGED
@@ -1,5 +1,13 @@
1
- import type { Config, RuntimeContext, Task, TransformContext, TransformHelper } from './types.js';
1
+ import type {
2
+ Config,
3
+ ObservableContext,
4
+ RuntimeContext,
5
+ Task,
6
+ TransformContext,
7
+ TransformHelper,
8
+ } from './types.js';
2
9
  import Listr from 'listr';
10
+ import { ReplaySubject } from 'rxjs';
3
11
  import chalk from 'chalk';
4
12
  import { getContentTypeId, getContentId } from './lib/contentful.js';
5
13
  import { setup } from './tasks/setup.js';
@@ -7,7 +15,7 @@ import { fetch } from './tasks/fetch.js';
7
15
  import { localize } from './tasks/localize.js';
8
16
  import { transform } from './tasks/transform.js';
9
17
  import { write } from './tasks/write.js';
10
- import { collectParentValues, collectValues } from './lib/utils.js';
18
+ import { collectParentValues, collectValues, waitFor } from './lib/utils.js';
11
19
  import { ValidationError } from './lib/error.js';
12
20
 
13
21
  /**
@@ -83,6 +91,8 @@ export const run = async (config: Config): Promise<void> => {
83
91
  const tasks = locales.map((locale) => ({
84
92
  title: `${locale.code}`,
85
93
  task: async () => {
94
+ const subject = new ReplaySubject<ObservableContext>();
95
+ const observable = subject.asObservable();
86
96
  const data = ctx.localized.get(locale.code);
87
97
  const { entries = [] } = data || {};
88
98
 
@@ -94,6 +104,7 @@ export const run = async (config: Config): Promise<void> => {
94
104
  const utils = {
95
105
  collectValues: collectValues({ ...data, entry }),
96
106
  collectParentValues: collectParentValues({ ...data, entry }),
107
+ waitFor: waitFor({ ...data, entry, observable }),
97
108
  } as TransformHelper;
98
109
 
99
110
  const transformContext: TransformContext = {
@@ -103,10 +114,12 @@ export const run = async (config: Config): Promise<void> => {
103
114
  entry,
104
115
  locale,
105
116
  utils,
117
+ observable,
106
118
  };
107
119
 
108
120
  try {
109
121
  const content = await transform(transformContext, ctx, config);
122
+ subject.next({ ...transformContext, content });
110
123
 
111
124
  if (typeof content === 'undefined') {
112
125
  return;
@@ -115,9 +128,16 @@ export const run = async (config: Config): Promise<void> => {
115
128
  await write({ ...transformContext, content }, ctx, config);
116
129
  ctx.stats.addSuccess(transformContext);
117
130
  } catch (error: unknown) {
131
+ if (error instanceof Error) {
132
+ subject.next({ ...transformContext, error });
133
+ } else {
134
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
135
+ subject.next({ ...transformContext, error: new Error(`${error}`) });
136
+ }
137
+
118
138
  if (error instanceof ValidationError) {
119
139
  ctx.stats.addSkipped(transformContext, error);
120
- } else if (typeof error === 'string' || error instanceof Error) {
140
+ } else {
121
141
  ctx.stats.addError(transformContext, error);
122
142
  }
123
143
  }
package/src/lib/error.ts CHANGED
@@ -17,3 +17,13 @@ export class ValidationError extends Error {
17
17
  this.name = 'ValidationError';
18
18
  }
19
19
  }
20
+
21
+ export class WrappedError extends Error {
22
+ originalError: unknown;
23
+
24
+ constructor(message: string, error: unknown) {
25
+ super(message);
26
+ this.originalError = error;
27
+ this.name = 'WrappedError';
28
+ }
29
+ }
package/src/lib/stats.ts CHANGED
@@ -28,11 +28,12 @@ export class Stats {
28
28
  this.success.push({ message, ...this.toEntry(context) });
29
29
  }
30
30
 
31
- addError(context: TransformContext, error: string | Error): void {
32
- if (typeof error === 'string') {
33
- this.errors.push({ error: new Error(error), ...this.toEntry(context) });
34
- } else {
31
+ addError(context: TransformContext, error: unknown): void {
32
+ if (error instanceof Error) {
35
33
  this.errors.push({ error, ...this.toEntry(context) });
34
+ } else {
35
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions
36
+ this.errors.push({ error: new Error(`${error}`), ...this.toEntry(context) });
36
37
  }
37
38
  }
38
39
 
@@ -1,5 +1,7 @@
1
1
  import { Entry, TransformContext } from '../types.js';
2
- import { collect, collectParentValues, collectValues } from './utils.js';
2
+ import { collect, collectParentValues, collectValues, waitFor } from './utils.js';
3
+ import { ReplaySubject } from 'rxjs';
4
+ import { WrappedError } from './error.js';
3
5
 
4
6
  const data = new Map([
5
7
  ['1', { sys: { id: '1' }, fields: { slug: 'a' } }],
@@ -9,12 +11,66 @@ const data = new Map([
9
11
  ['5', { sys: { id: '5' }, fields: { slug: 'e', parent: '4' } }],
10
12
  ]) as unknown as Map<string, Entry>;
11
13
 
14
+ const contentType = { sys: { id: 'test-type' } };
15
+
12
16
  const entryMap = new Map([
13
- ['1', { sys: { id: '1' }, fields: { slug: 'a' } }],
14
- ['2', { sys: { id: '2' }, fields: { slug: 'b', parent: { sys: { id: '1' } }, link: { sys: { id: '1' } } } }],
15
- ['3', { sys: { id: '3' }, fields: { slug: 'c', parent: { sys: { id: '2' } }, link: { sys: { id: '1' } } } }],
16
- ['4', { sys: { id: '4' }, fields: { slug: 'd', parent: { sys: { id: '3' } }, link: { sys: { id: '3' } } } }],
17
- ['5', { sys: { id: '5' }, fields: { slug: 'e', parent: { sys: { id: '4' } }, link: { sys: { id: '3' } } } }],
17
+ ['1', { sys: { id: '1', contentType }, fields: { slug: 'a' } }],
18
+ [
19
+ '2',
20
+ {
21
+ sys: { id: '2', contentType },
22
+ fields: { slug: 'b', parent: { sys: { id: '1' } }, link: { sys: { id: '1' } } },
23
+ },
24
+ ],
25
+ [
26
+ '3',
27
+ {
28
+ sys: { id: '3', contentType },
29
+ fields: { slug: 'c', parent: { sys: { id: '2' } }, link: { sys: { id: '1' } } },
30
+ },
31
+ ],
32
+ [
33
+ '4',
34
+ {
35
+ sys: { id: '4', contentType },
36
+ fields: { slug: 'd', parent: { sys: { id: '3' } }, link: { sys: { id: '3' } } },
37
+ },
38
+ ],
39
+ [
40
+ '5',
41
+ {
42
+ sys: { id: '5', contentType },
43
+ fields: { slug: 'e', parent: { sys: { id: '4' } }, link: { sys: { id: '3' } } },
44
+ },
45
+ ],
46
+ [
47
+ '6',
48
+ {
49
+ sys: { id: '6', contentType },
50
+ fields: { slug: 'f', parent: { sys: { id: '1' } }, link: { sys: { id: '1' } } },
51
+ },
52
+ ],
53
+ [
54
+ '7',
55
+ {
56
+ sys: { id: '7', contentType },
57
+ fields: { slug: 'g', parent: { sys: { id: '2' } }, link: { sys: { id: '1' } } },
58
+ },
59
+ ],
60
+ [
61
+ '8',
62
+ {
63
+ sys: { id: '8', contentType },
64
+ fields: { slug: 'h', parent: { sys: { id: '3' } }, link: { sys: { id: '3' } } },
65
+ },
66
+ ],
67
+ [
68
+ '9',
69
+ {
70
+ sys: { id: '9', contentType },
71
+ fields: { slug: 'i', parent: { sys: { id: '4' } }, link: { sys: { id: '3' } } },
72
+ },
73
+ ],
18
74
  ]) as unknown as Map<string, Entry>;
19
75
 
20
76
  const transformContext = { entryMap } as unknown as TransformContext;
@@ -82,4 +138,161 @@ describe('Utils', () => {
82
138
  expect(a).toEqual(['a', 'b', 'c', 'd', 'e']);
83
139
  expect(b).toEqual(['e', 'd', 'c', 'b', 'a']);
84
140
  });
141
+
142
+ test('waitFor', async () => {
143
+ const subject = new ReplaySubject<TransformContext>(null);
144
+ const observable = subject.asObservable();
145
+
146
+ // Throw error when waiting for the current entry
147
+ expect(async () => {
148
+ await waitFor({ ...transformContext, entry: entryMap.get('2'), observable })('2');
149
+ }).rejects.toThrowError(/2.*waiting.*2/);
150
+
151
+ // Throw error when waiting for non existing entry
152
+ expect(async () => {
153
+ await waitFor({ ...transformContext, entry: entryMap.get('3'), observable })('10');
154
+ }).rejects.toThrowError('No entry with id "10" available');
155
+
156
+ // // Throw error when waiting timeout is reached
157
+ await expect(async () => {
158
+ await waitFor({ ...transformContext, entry: entryMap.get('1'), observable })('4', 50);
159
+ }).rejects.toThrowError(/Exceeded timeout of 50 ms/);
160
+
161
+ // Mimic 500ms wait time for entry
162
+ setTimeout(() => {
163
+ subject.next({ ...transformContext, entry: entryMap.get('4'), observable });
164
+ }, 500);
165
+
166
+ const value = await waitFor({ ...transformContext, entry: entryMap.get('1'), observable })('4');
167
+ expect(value).toEqual({ ...transformContext, entry: entryMap.get('4'), observable });
168
+ });
169
+
170
+ test('waitFor error', async () => {
171
+ const subject = new ReplaySubject<TransformContext>(null);
172
+ const observable = subject.asObservable();
173
+ const entry = entryMap.get('3');
174
+
175
+ // Mimic 500ms wait time for entry
176
+ setTimeout(() => {
177
+ subject.next({
178
+ ...transformContext,
179
+ entry: entryMap.get('4'),
180
+ observable,
181
+ error: new Error('test error'),
182
+ });
183
+ }, 500);
184
+
185
+ await expect(async () => {
186
+ await waitFor({ ...transformContext, entry, observable })('4');
187
+ }).rejects.toThrowError(WrappedError);
188
+
189
+ try {
190
+ await waitFor({ ...transformContext, entry, observable })('4');
191
+ } catch (error) {
192
+ expect(error).toBeInstanceOf(WrappedError);
193
+ expect(error.message).toMatch('Awaited entry 4 (test-type) errored');
194
+ expect(error.originalError.message).toEqual('test error');
195
+ }
196
+ });
197
+
198
+ test('detect cyclic dependency', async () => {
199
+ const subject = new ReplaySubject<TransformContext>(null);
200
+ const observable = subject.asObservable();
201
+
202
+ // let 9 finish regularly
203
+ setTimeout(() => {
204
+ subject.next({
205
+ ...transformContext,
206
+ entry: entryMap.get('9'),
207
+ observable,
208
+ });
209
+ }, 100);
210
+
211
+ // mimic behaviour in index.ts
212
+ const waitMock = async (source: string, dest: string, delay: number) => {
213
+ await new Promise((resolve) => setTimeout(resolve, delay));
214
+ try {
215
+ await waitFor({ ...transformContext, entry: entryMap.get(source), observable })(dest, 1000);
216
+ subject.next({
217
+ ...transformContext,
218
+ entry: entryMap.get(source),
219
+ observable,
220
+ });
221
+ return `SUCCESS ${source}`;
222
+ } catch (error) {
223
+ subject.next({
224
+ ...transformContext,
225
+ entry: entryMap.get(source),
226
+ error,
227
+ observable,
228
+ });
229
+ return `${error.message}`;
230
+ }
231
+ };
232
+
233
+ const result = await Promise.all([
234
+ waitMock('6', '8', 0),
235
+ waitMock('8', '5', 10),
236
+ waitMock('5', '7', 20),
237
+ waitMock('7', '6', 30),
238
+ waitMock('3', '9', 40),
239
+ ]);
240
+
241
+ expect(result[0]).toMatch('Found cyclic dependency in 6 (test-type): 6 -> 8 -> 5 -> 7 -> 6');
242
+ expect(result[1]).toMatch('Found cyclic dependency in 8 (test-type): 8 -> 5 -> 7 -> 6 -> 8');
243
+ expect(result[2]).toMatch('Found cyclic dependency in 5 (test-type): 5 -> 7 -> 6 -> 8 -> 5');
244
+ expect(result[3]).toMatch(/Awaited entry 6 \(test-type\) errored/);
245
+ expect(result[4]).toMatch('SUCCESS 3');
246
+ });
247
+
248
+ test('handle subscriptions on postponed subscribe', async () => {
249
+ const subject = new ReplaySubject<TransformContext>();
250
+ const observable = subject.asObservable();
251
+
252
+ const finishSuccess = (id, time = 0) =>
253
+ setTimeout(() => {
254
+ subject.next({
255
+ ...transformContext,
256
+ entry: entryMap.get(id),
257
+ observable,
258
+ });
259
+ }, time);
260
+
261
+ const finishError = (id, time = 0) =>
262
+ setTimeout(() => {
263
+ subject.next({
264
+ ...transformContext,
265
+ entry: entryMap.get(id),
266
+ error: new Error(`Error on ${id}`),
267
+ observable,
268
+ });
269
+ }, time);
270
+
271
+ finishSuccess('2', 20);
272
+ finishSuccess('3', 40);
273
+ finishError('4', 30);
274
+ finishSuccess('5', 50);
275
+
276
+ const transFormMock = () =>
277
+ Promise.all([
278
+ waitFor({ ...transformContext, entry: entryMap.get('1'), observable })('2'),
279
+ waitFor({ ...transformContext, entry: entryMap.get('1'), observable })('3'),
280
+ waitFor({ ...transformContext, entry: entryMap.get('1'), observable })('4'),
281
+ waitFor({ ...transformContext, entry: entryMap.get('1'), observable })('5'),
282
+ ]);
283
+
284
+ const result = await new Promise((resolve) => {
285
+ setTimeout(async () => {
286
+ try {
287
+ const result = await transFormMock();
288
+ resolve(result);
289
+ } catch (error) {
290
+ resolve(error);
291
+ }
292
+ }, 500);
293
+ });
294
+
295
+ expect(result).toBeInstanceOf(WrappedError);
296
+ expect((result as WrappedError).message).toMatch(/Awaited entry 4 \(test-type\) errored/);
297
+ });
85
298
  });
package/src/lib/utils.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { CollectOptions, Entry, TransformContext } from '../types.js';
2
+ import { getContentId, getContentTypeId } from './contentful.js';
3
+ import { ReplaySubject, map, distinct, filter } from 'rxjs';
2
4
  import dlv from 'dlv';
5
+ import { WrappedError } from './error.js';
6
+
7
+ // eslint-disable-next-line @typescript-eslint/naming-convention
8
+ const DEFAULT_WAIT_TIMEOUT = 20000;
3
9
 
4
10
  export const collectValues =
5
11
  (transformContext: Pick<TransformContext, 'entry' | 'entryMap'>) =>
@@ -55,3 +61,96 @@ export const collect = <T = unknown>(
55
61
 
56
62
  return [value];
57
63
  };
64
+
65
+ const dependencies: Record<string, string[]> = {};
66
+
67
+ const buildDependencies = (v: { source: string; dest: string }) => {
68
+ if ((dependencies?.[v.source] ?? []).includes(v.dest)) {
69
+ return dependencies;
70
+ }
71
+
72
+ if (dependencies[v.source]) {
73
+ dependencies[v.source] = [...dependencies[v.source], v.dest];
74
+ } else {
75
+ dependencies[v.source] = [v.dest];
76
+ }
77
+
78
+ Object.entries({ ...dependencies })
79
+ .filter(([, value]) => value.includes(v.source))
80
+ .forEach(([key, value]) => {
81
+ dependencies[key] = [...new Set([...value, v.dest, ...(dependencies[v.dest] || [])])];
82
+ });
83
+
84
+ return dependencies;
85
+ };
86
+
87
+ const waitForSubject = new ReplaySubject<{ source: string; dest: string }>();
88
+
89
+ const cyclicErrorObservable = waitForSubject
90
+ .asObservable()
91
+ .pipe(map((value) => buildDependencies(value)));
92
+
93
+ /**
94
+ * Wait for entry to be transformed
95
+ */
96
+ export const waitFor = (
97
+ transformContext: Pick<TransformContext, 'entry' | 'observable' | 'entryMap'>
98
+ ) => {
99
+ const sourceEntry = transformContext.entry;
100
+ const sourceId = getContentId(sourceEntry);
101
+ const sourceContentTypeId = getContentTypeId(sourceEntry);
102
+ const source = `${sourceId} (${sourceContentTypeId})`;
103
+
104
+ return async (id: string, waitTimeout = DEFAULT_WAIT_TIMEOUT) => {
105
+ // Make sure we don't try to wait for the current entry
106
+ if (sourceId === id) {
107
+ throw new Error(`Can't wait for yourself.
108
+ Entry ${source} waiting for ${source}.`);
109
+ }
110
+
111
+ waitForSubject.next({ source: getContentId(sourceEntry), dest: id });
112
+ const destEntry = transformContext.entryMap.get(id);
113
+ const destId = getContentId(destEntry);
114
+ const destContentTypeId = getContentTypeId(destEntry);
115
+ const dest = `${destId} (${destContentTypeId})`;
116
+
117
+ return new Promise((resolve, reject) => {
118
+ // If anything goes wrong, reject promise after waitTimeout
119
+ const timeout = setTimeout(() => {
120
+ reject(
121
+ new Error(
122
+ `Exceeded timeout of ${waitTimeout} ms while waiting for entry ${id} to complete.
123
+ Entry ${source} waiting for ${dest}.`
124
+ )
125
+ );
126
+ }, waitTimeout);
127
+
128
+ if (transformContext.entryMap.has(id)) {
129
+ transformContext.observable
130
+ .pipe(filter((ctx) => ctx?.entry?.sys?.id === id))
131
+ .subscribe((value) => {
132
+ clearTimeout(timeout);
133
+ if (value.error) {
134
+ reject(new WrappedError(`Awaited entry ${dest} errored`, value.error));
135
+ } else {
136
+ resolve(value);
137
+ }
138
+ });
139
+
140
+ cyclicErrorObservable
141
+ .pipe(filter((v) => v?.[sourceId]?.includes(sourceId)))
142
+ .pipe(distinct())
143
+ .subscribe((v) => {
144
+ clearTimeout(timeout);
145
+ const deps = v?.[sourceId] ?? [];
146
+ reject(
147
+ new Error(`Found cyclic dependency in ${source}: ${[sourceId, ...deps].join(' -> ')}`)
148
+ );
149
+ });
150
+ } else {
151
+ clearTimeout(timeout);
152
+ reject(new Error(`No entry with id "${id}" available`));
153
+ }
154
+ });
155
+ };
156
+ };
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Options } from '@contentful/rich-text-html-renderer';
2
2
  import type { Document } from '@contentful/rich-text-types';
3
+ import type { Observable } from 'rxjs';
3
4
  import type { QueryOptions, CollectionProp } from 'contentful-management/types';
4
5
 
5
6
  import type {
@@ -202,6 +203,7 @@ export type Task = ListrTaskObject<RuntimeContext>;
202
203
  export interface TransformHelper {
203
204
  collectValues: <T>(key, options?: CollectOptions) => T[];
204
205
  collectParentValues: <T>(key, options?: CollectOptions) => T[];
206
+ waitFor: (id: string, waitTimeout?: number) => Promise<ObservableContext>;
205
207
  }
206
208
 
207
209
  export type TransformContext = LocalizedContent & {
@@ -217,8 +219,14 @@ export type TransformContext = LocalizedContent & {
217
219
  fieldSettings?: Field;
218
220
  requiredFields?: string[];
219
221
  utils: TransformHelper;
222
+ observable: Observable<ObservableContext>;
220
223
  };
221
224
 
225
+ export type ObservableContext = Readonly<
226
+ Pick<TransformContext, 'id' | 'contentTypeId' | 'entry' | 'content' | 'locale'> & {
227
+ error?: Error;
228
+ }
229
+ >;
222
230
  export interface Ignore {
223
231
  add(pattern: string | Ignore | string[] | Ignore[]): Ignore;
224
232
  filter(paths: string[]): string[];