@friggframework/devtools 2.0.0--canary.482.563eae6.0 → 2.0.0--canary.482.217a30b.0

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.
@@ -3,32 +3,65 @@ const fs = require('fs-extra');
3
3
  const { composeServerlessDefinition } = require('./infrastructure-composer');
4
4
  const { findNearestBackendPackageJson } = require('@friggframework/core');
5
5
 
6
+ // Memoization cache to prevent duplicate infrastructure composition
7
+ // when serverless framework loads configuration multiple times
8
+ let cachedInfrastructure = null;
9
+ let isComposing = false;
10
+
6
11
  async function createFriggInfrastructure() {
7
- const backendPath = findNearestBackendPackageJson();
8
- if (!backendPath) {
9
- throw new Error('Could not find backend package.json');
12
+ // Return cached infrastructure if already composed
13
+ if (cachedInfrastructure) {
14
+ console.log(' Using cached infrastructure definition (already composed)');
15
+ return cachedInfrastructure;
10
16
  }
11
17
 
12
- const backendDir = path.dirname(backendPath);
13
- const backendFilePath = path.join(backendDir, 'index.js');
14
- if (!fs.existsSync(backendFilePath)) {
15
- throw new Error('Could not find index.js');
18
+ // Wait if another call is currently composing
19
+ if (isComposing) {
20
+ console.log('⏳ Infrastructure composition in progress - waiting...');
21
+ // Poll every 100ms until composition completes
22
+ while (isComposing) {
23
+ await new Promise(resolve => setTimeout(resolve, 100));
24
+ }
25
+ // Return the newly cached infrastructure
26
+ return cachedInfrastructure;
16
27
  }
17
28
 
18
- const backend = require(backendFilePath);
19
- const appDefinition = backend.Definition;
29
+ // Mark as composing to prevent concurrent composition
30
+ isComposing = true;
31
+
32
+ try {
33
+ const backendPath = findNearestBackendPackageJson();
34
+ if (!backendPath) {
35
+ throw new Error('Could not find backend package.json');
36
+ }
37
+
38
+ const backendDir = path.dirname(backendPath);
39
+ const backendFilePath = path.join(backendDir, 'index.js');
40
+ if (!fs.existsSync(backendFilePath)) {
41
+ throw new Error('Could not find index.js');
42
+ }
20
43
 
21
- // const serverlessTemplate = require(path.resolve(
22
- // __dirname,
23
- // './serverless-template.js'
24
- // ));
25
- const definition = await composeServerlessDefinition(
26
- appDefinition,
27
- );
44
+ const backend = require(backendFilePath);
45
+ const appDefinition = backend.Definition;
28
46
 
29
- return {
30
- ...definition,
31
- };
47
+ // const serverlessTemplate = require(path.resolve(
48
+ // __dirname,
49
+ // './serverless-template.js'
50
+ // ));
51
+ const definition = await composeServerlessDefinition(
52
+ appDefinition,
53
+ );
54
+
55
+ // Cache the composed infrastructure
56
+ cachedInfrastructure = {
57
+ ...definition,
58
+ };
59
+
60
+ return cachedInfrastructure;
61
+ } finally {
62
+ // Always clear composing flag
63
+ isComposing = false;
64
+ }
32
65
  }
33
66
 
34
67
  module.exports = { createFriggInfrastructure };
@@ -11,6 +11,21 @@ const path = require('path');
11
11
  const fs = require('fs');
12
12
  const { buildPrismaLayer } = require('../../../scripts/build-prisma-layer');
13
13
 
14
+ /**
15
+ * Check if a process is still running
16
+ * @param {number} pid - Process ID to check
17
+ * @returns {boolean} True if process is running
18
+ */
19
+ function isProcessRunning(pid) {
20
+ try {
21
+ // Signal 0 checks if process exists without killing it
22
+ process.kill(pid, 0);
23
+ return true;
24
+ } catch (error) {
25
+ return false;
26
+ }
27
+ }
28
+
14
29
  /**
15
30
  * Ensure Prisma Lambda Layer exists
16
31
  *
@@ -18,10 +33,11 @@ const { buildPrismaLayer } = require('../../../scripts/build-prisma-layer');
18
33
  * The layer contains ONLY the Prisma runtime client (minimal, ~10-15MB).
19
34
  * Prisma CLI is bundled separately in the dbMigrate function.
20
35
  *
21
- * Domain Concept: Build Completion State
36
+ * Domain Concept: Build Completion State & Process Locking
22
37
  * - Uses .build-complete marker file to track successful builds
23
- * - Prevents race conditions during concurrent infrastructure composition
24
- * - Cleans incomplete builds before retry
38
+ * - Uses .build-lock PID file to prevent concurrent builds
39
+ * - Waits for active builds to complete before starting new one
40
+ * - Cleans stale locks and incomplete builds before retry
25
41
  *
26
42
  * @param {Object} databaseConfig - Database configuration from app definition
27
43
  * @returns {Promise<void>}
@@ -31,6 +47,7 @@ async function ensurePrismaLayerExists(databaseConfig = {}) {
31
47
  const projectRoot = process.cwd();
32
48
  const layerPath = path.join(projectRoot, 'layers/prisma');
33
49
  const completionMarkerPath = path.join(layerPath, '.build-complete');
50
+ const lockFilePath = path.join(layerPath, '.build-lock');
34
51
 
35
52
  // Check if build is complete (marker exists)
36
53
  if (fs.existsSync(completionMarkerPath)) {
@@ -38,22 +55,50 @@ async function ensurePrismaLayerExists(databaseConfig = {}) {
38
55
  return;
39
56
  }
40
57
 
41
- // Check if incomplete build exists (directory without marker)
42
- if (fs.existsSync(layerPath)) {
43
- console.log('⚠ Incomplete Prisma layer detected');
58
+ // Check for active build process
59
+ if (fs.existsSync(lockFilePath)) {
60
+ const lockPid = parseInt(fs.readFileSync(lockFilePath, 'utf-8').trim(), 10);
61
+
62
+ if (isProcessRunning(lockPid)) {
63
+ console.log(`⏳ Another build process (PID ${lockPid}) is active - waiting...`);
64
+
65
+ // Wait up to 60 seconds for the other process to complete
66
+ for (let i = 0; i < 60; i++) {
67
+ await new Promise(resolve => setTimeout(resolve, 1000));
68
+
69
+ // Check if build completed
70
+ if (fs.existsSync(completionMarkerPath)) {
71
+ console.log('✓ Concurrent build completed successfully');
72
+ return;
73
+ }
74
+
75
+ // Check if process died
76
+ if (!isProcessRunning(lockPid)) {
77
+ console.log(`⚠ Build process ${lockPid} terminated - cleaning up stale lock`);
78
+ fs.rmSync(lockFilePath, { force: true });
79
+ break;
80
+ }
81
+ }
44
82
 
45
- // Wait briefly to see if another process completes the build
46
- console.log('⏳ Checking if concurrent build is in progress...');
47
- await new Promise(resolve => setTimeout(resolve, 1000));
83
+ // Timeout - check one final time
84
+ if (fs.existsSync(completionMarkerPath)) {
85
+ console.log('✓ Concurrent build completed');
86
+ return;
87
+ }
48
88
 
49
- // Check if concurrent build completed while we waited
50
- if (fs.existsSync(completionMarkerPath)) {
51
- console.log('✓ Concurrent build completed');
52
- return;
89
+ // Still locked after 60s - remove stale lock
90
+ console.log('⚠ Build timeout - removing stale lock and rebuilding');
91
+ fs.rmSync(lockFilePath, { force: true });
92
+ } else {
93
+ // Stale lock file (process not running)
94
+ console.log(`⚠ Stale lock file detected (PID ${lockPid} not running) - cleaning up`);
95
+ fs.rmSync(lockFilePath, { force: true });
53
96
  }
97
+ }
54
98
 
55
- // Incomplete build still exists - will be cleaned by buildPrismaLayer()
56
- console.log('⚠ Proceeding with rebuild (buildPrismaLayer will clean incomplete build)');
99
+ // Check if incomplete build exists (directory without marker)
100
+ if (fs.existsSync(layerPath) && !fs.existsSync(completionMarkerPath)) {
101
+ console.log('⚠ Incomplete Prisma layer detected - will be cleaned by buildPrismaLayer()');
57
102
  }
58
103
 
59
104
  // Build layer
@@ -62,6 +107,16 @@ async function ensurePrismaLayerExists(databaseConfig = {}) {
62
107
  console.log(' CLI is packaged separately in dbMigrate function');
63
108
  console.log(' This may take a minute on first deployment.\n');
64
109
 
110
+ // Create lock file with current process PID
111
+ try {
112
+ if (!fs.existsSync(layerPath)) {
113
+ fs.mkdirSync(layerPath, { recursive: true });
114
+ }
115
+ fs.writeFileSync(lockFilePath, process.pid.toString());
116
+ } catch (error) {
117
+ console.warn('⚠ Could not create lock file:', error.message);
118
+ }
119
+
65
120
  try {
66
121
  // Build layer WITHOUT CLI (runtime only for minimal size)
67
122
  await buildPrismaLayer(databaseConfig);
@@ -86,6 +141,15 @@ async function ensurePrismaLayerExists(databaseConfig = {}) {
86
141
  console.error('✗ Failed to build Prisma Lambda Layer:', error.message);
87
142
  console.error(' You may need to run: npm install @friggframework/core\n');
88
143
  throw error;
144
+ } finally {
145
+ // Always remove lock file when done (success or failure)
146
+ if (fs.existsSync(lockFilePath)) {
147
+ try {
148
+ fs.rmSync(lockFilePath, { force: true });
149
+ } catch (error) {
150
+ console.warn('⚠ Could not remove lock file:', error.message);
151
+ }
152
+ }
89
153
  }
90
154
  }
91
155
 
@@ -179,6 +179,144 @@ describe('Prisma Layer Manager', () => {
179
179
  expect(buildPrismaLayer).not.toHaveBeenCalled();
180
180
  });
181
181
 
182
+ it('should wait for active build process to complete (TDD)', async () => {
183
+ jest.useFakeTimers();
184
+
185
+ // Mock active lock file with running process
186
+ const activePid = 12345;
187
+ let completionMarkerExists = false;
188
+ fs.existsSync = jest.fn((path) => {
189
+ if (path.endsWith('.build-complete')) return completionMarkerExists;
190
+ if (path.endsWith('.build-lock')) return true;
191
+ if (path.endsWith('layers/prisma')) return true;
192
+ return false;
193
+ });
194
+ fs.readFileSync = jest.fn().mockReturnValue(activePid.toString());
195
+ fs.writeFileSync = jest.fn();
196
+ fs.rmSync = jest.fn();
197
+
198
+ // Mock process.kill to simulate running process, then completion
199
+ let killCallCount = 0;
200
+ const originalKill = process.kill;
201
+ process.kill = jest.fn((pid, signal) => {
202
+ killCallCount++;
203
+ if (killCallCount >= 3) {
204
+ // After 3 seconds, build completes
205
+ completionMarkerExists = true;
206
+ }
207
+ return true; // Process is running
208
+ });
209
+
210
+ const promise = ensurePrismaLayerExists();
211
+
212
+ // Fast-forward through the wait loop
213
+ for (let i = 0; i < 5; i++) {
214
+ jest.advanceTimersByTime(1000);
215
+ await Promise.resolve();
216
+ }
217
+
218
+ await promise;
219
+
220
+ // Should not rebuild (waited for concurrent build)
221
+ expect(buildPrismaLayer).not.toHaveBeenCalled();
222
+
223
+ process.kill = originalKill;
224
+ jest.useRealTimers();
225
+ });
226
+
227
+ it('should clean stale lock file if process not running (TDD)', async () => {
228
+ // Mock stale lock file (process not running)
229
+ fs.existsSync = jest.fn((path) => {
230
+ if (path.endsWith('.build-complete')) return false;
231
+ if (path.endsWith('.build-lock')) return true;
232
+ if (path.endsWith('layers/prisma')) return false;
233
+ return false;
234
+ });
235
+ fs.readFileSync = jest.fn().mockReturnValue('99999');
236
+ fs.writeFileSync = jest.fn();
237
+ fs.mkdirSync = jest.fn();
238
+ fs.rmSync = jest.fn();
239
+
240
+ // Mock process.kill to throw (process not running)
241
+ const originalKill = process.kill;
242
+ process.kill = jest.fn(() => {
243
+ throw new Error('ESRCH');
244
+ });
245
+
246
+ buildPrismaLayer.mockResolvedValue();
247
+
248
+ await ensurePrismaLayerExists();
249
+
250
+ // Should remove stale lock
251
+ expect(fs.rmSync).toHaveBeenCalledWith('/project/layers/prisma/.build-lock', { force: true });
252
+ // Should proceed with build
253
+ expect(buildPrismaLayer).toHaveBeenCalled();
254
+
255
+ process.kill = originalKill;
256
+ });
257
+
258
+ it('should create and remove lock file during build (TDD)', async () => {
259
+ // Mock to simulate successful build flow
260
+ fs.existsSync = jest.fn((path) => {
261
+ // Completion marker doesn't exist initially
262
+ if (path.endsWith('.build-complete')) return false;
263
+ // Lock file exists in finally block (after writeFileSync)
264
+ if (path.endsWith('.build-lock')) return true;
265
+ // Directory doesn't exist initially
266
+ if (path.endsWith('layers/prisma')) return false;
267
+ return false;
268
+ });
269
+ fs.writeFileSync = jest.fn();
270
+ fs.mkdirSync = jest.fn();
271
+ fs.rmSync = jest.fn();
272
+ buildPrismaLayer.mockResolvedValue();
273
+
274
+ await ensurePrismaLayerExists();
275
+
276
+ // Should create directory for lock file
277
+ expect(fs.mkdirSync).toHaveBeenCalledWith('/project/layers/prisma', { recursive: true });
278
+
279
+ // Should create lock file with PID
280
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
281
+ '/project/layers/prisma/.build-lock',
282
+ expect.any(String)
283
+ );
284
+
285
+ // Should create completion marker
286
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
287
+ '/project/layers/prisma/.build-complete',
288
+ expect.any(String)
289
+ );
290
+
291
+ // Should remove lock file in finally block
292
+ expect(fs.rmSync).toHaveBeenCalledWith(
293
+ '/project/layers/prisma/.build-lock',
294
+ { force: true }
295
+ );
296
+ });
297
+
298
+ it('should remove lock file even if build fails (TDD)', async () => {
299
+ mockFs.noBuild();
300
+ fs.mkdirSync = jest.fn();
301
+ buildPrismaLayer.mockRejectedValue(new Error('Build failed'));
302
+
303
+ // After failure, directory exists
304
+ fs.existsSync = jest.fn((path) => {
305
+ if (path.endsWith('.build-complete')) return false;
306
+ if (path.endsWith('.build-lock')) return true;
307
+ if (path.endsWith('layers/prisma')) return true;
308
+ return false;
309
+ });
310
+
311
+ await expect(ensurePrismaLayerExists()).rejects.toThrow('Build failed');
312
+
313
+ // Should remove lock file in finally block
314
+ expect(fs.rmSync).toHaveBeenCalledWith(
315
+ '/project/layers/prisma/.build-lock',
316
+ { force: true }
317
+ );
318
+ });
319
+
182
320
  it('should wait and rebuild if directory exists without completion marker (TDD)', async () => {
183
321
  jest.useFakeTimers();
184
322
  mockFs.incompleteBuild();
@@ -258,29 +396,46 @@ describe('Prisma Layer Manager', () => {
258
396
  it('should detect when concurrent process completes during wait (TDD)', async () => {
259
397
  jest.useFakeTimers();
260
398
 
261
- // After 1 second, completion marker appears (concurrent process finished)
262
- let callCount = 0;
399
+ // Simulate lock file existing with completion happening during wait
400
+ let completionMarkerExists = false;
401
+ let lockFileExists = true;
263
402
  fs.existsSync = jest.fn((path) => {
264
- callCount++;
265
- if (path.endsWith('.build-complete')) {
266
- return callCount > 2; // Appears after setTimeout
267
- }
403
+ if (path.endsWith('.build-complete')) return completionMarkerExists;
404
+ if (path.endsWith('.build-lock')) return lockFileExists;
268
405
  if (path.endsWith('layers/prisma')) return true;
269
406
  return false;
270
407
  });
408
+ fs.readFileSync = jest.fn().mockReturnValue('12345');
271
409
  fs.writeFileSync = jest.fn();
272
410
  fs.rmSync = jest.fn();
273
411
 
412
+ // Mock process.kill to simulate active process
413
+ let killCallCount = 0;
414
+ const originalKill = process.kill;
415
+ process.kill = jest.fn((pid, signal) => {
416
+ killCallCount++;
417
+ if (killCallCount >= 3) {
418
+ // After 3 checks, build completes
419
+ completionMarkerExists = true;
420
+ lockFileExists = false;
421
+ }
422
+ return true; // Process is running
423
+ });
424
+
274
425
  const promise = ensurePrismaLayerExists();
275
426
 
276
- // Fast-forward through the wait
277
- jest.advanceTimersByTime(1000);
427
+ // Fast-forward through the wait loop
428
+ for (let i = 0; i < 5; i++) {
429
+ jest.advanceTimersByTime(1000);
430
+ await Promise.resolve();
431
+ }
278
432
 
279
433
  await promise;
280
434
 
281
435
  // Should not rebuild (concurrent process completed)
282
436
  expect(buildPrismaLayer).not.toHaveBeenCalled();
283
437
 
438
+ process.kill = originalKill;
284
439
  jest.useRealTimers();
285
440
  });
286
441
  });
@@ -0,0 +1,3 @@
1
+ Build completed: 2025-10-31T04:18:27.501Z
2
+ Node: v23.5.0
3
+ Platform: darwin
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/devtools",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.482.563eae6.0",
4
+ "version": "2.0.0--canary.482.217a30b.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-ec2": "^3.835.0",
7
7
  "@aws-sdk/client-kms": "^3.835.0",
@@ -12,8 +12,8 @@
12
12
  "@babel/eslint-parser": "^7.18.9",
13
13
  "@babel/parser": "^7.25.3",
14
14
  "@babel/traverse": "^7.25.3",
15
- "@friggframework/schemas": "2.0.0--canary.482.563eae6.0",
16
- "@friggframework/test": "2.0.0--canary.482.563eae6.0",
15
+ "@friggframework/schemas": "2.0.0--canary.482.217a30b.0",
16
+ "@friggframework/test": "2.0.0--canary.482.217a30b.0",
17
17
  "@hapi/boom": "^10.0.1",
18
18
  "@inquirer/prompts": "^5.3.8",
19
19
  "axios": "^1.7.2",
@@ -35,8 +35,8 @@
35
35
  "serverless-http": "^2.7.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@friggframework/eslint-config": "2.0.0--canary.482.563eae6.0",
39
- "@friggframework/prettier-config": "2.0.0--canary.482.563eae6.0",
38
+ "@friggframework/eslint-config": "2.0.0--canary.482.217a30b.0",
39
+ "@friggframework/prettier-config": "2.0.0--canary.482.217a30b.0",
40
40
  "aws-sdk-client-mock": "^4.1.0",
41
41
  "aws-sdk-client-mock-jest": "^4.1.0",
42
42
  "jest": "^30.1.3",
@@ -68,5 +68,5 @@
68
68
  "publishConfig": {
69
69
  "access": "public"
70
70
  },
71
- "gitHead": "563eae6ba575d187ac24202d571fe7b8b462f398"
71
+ "gitHead": "217a30b7d081d55d1606685312b468a2a2a1ed25"
72
72
  }