@superblocksteam/sdk 2.0.131-next.1 → 2.0.132-next.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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/cli-replacement/dev-s3-restore.test.mjs +327 -24
- package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +0 -1
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.d.mts.map +1 -1
- package/dist/cli-replacement/dev.mjs +140 -68
- package/dist/cli-replacement/dev.mjs.map +1 -1
- package/dist/dbfs/client.d.ts.map +1 -1
- package/dist/dbfs/client.js +58 -16
- package/dist/dbfs/client.js.map +1 -1
- package/dist/dbfs/client.test.js +67 -7
- package/dist/dbfs/client.test.js.map +1 -1
- package/dist/dev-utils/dev-server.mjs +3 -1
- package/dist/dev-utils/dev-server.mjs.map +1 -1
- package/package.json +6 -6
- package/src/cli-replacement/dev-s3-restore.test.mts +465 -28
- package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +0 -1
- package/src/cli-replacement/dev.mts +174 -87
- package/src/dbfs/client.test.ts +115 -7
- package/src/dbfs/client.ts +75 -21
- package/src/dev-utils/dev-server.mts +3 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
ConflictError,
|
|
19
19
|
DEFAULT_NON_GIT_BRANCH,
|
|
20
20
|
isGitHubRemoteUrl,
|
|
21
|
-
NotFoundError,
|
|
22
21
|
SUPERBLOCKS_LIVE_GIT_BRANCH,
|
|
23
22
|
} from "@superblocksteam/shared";
|
|
24
23
|
import { maskUnixSignals } from "@superblocksteam/util";
|
|
@@ -234,6 +233,17 @@ async function readPackageLock(cwd: string): Promise<string | null> {
|
|
|
234
233
|
}
|
|
235
234
|
}
|
|
236
235
|
|
|
236
|
+
async function readInstalledPackageLock(cwd: string): Promise<string | null> {
|
|
237
|
+
try {
|
|
238
|
+
return await nodeFs.readFile(
|
|
239
|
+
path.join(cwd, "node_modules", ".package-lock.json"),
|
|
240
|
+
"utf8",
|
|
241
|
+
);
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
237
247
|
/**
|
|
238
248
|
* Canonicalize a lockfile for post-install change detection. Every boot,
|
|
239
249
|
* `stripResolvedFromLockfile` (APPS-4300) removes the `resolved` URLs and
|
|
@@ -262,23 +272,120 @@ export function lockfileComparisonKey(raw: string | null): string | null {
|
|
|
262
272
|
}
|
|
263
273
|
}
|
|
264
274
|
|
|
265
|
-
|
|
275
|
+
function registryUrlMatches(resolved: string, registryUrl: string): boolean {
|
|
276
|
+
try {
|
|
277
|
+
const resolvedUrl = new URL(resolved);
|
|
278
|
+
const registry = new URL(registryUrl);
|
|
279
|
+
return (
|
|
280
|
+
resolvedUrl.protocol === registry.protocol &&
|
|
281
|
+
resolvedUrl.host === registry.host &&
|
|
282
|
+
resolvedUrl.pathname.startsWith(registry.pathname.replace(/\/?$/, "/"))
|
|
283
|
+
);
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function packageNameFromLockfilePath(lockfilePath: string): string | null {
|
|
290
|
+
const parts = lockfilePath.split("/");
|
|
291
|
+
const nodeModulesIndex = parts.lastIndexOf("node_modules");
|
|
292
|
+
if (nodeModulesIndex < 0) return null;
|
|
293
|
+
const first = parts[nodeModulesIndex + 1];
|
|
294
|
+
if (!first) return null;
|
|
295
|
+
if (first.startsWith("@")) {
|
|
296
|
+
const second = parts[nodeModulesIndex + 2];
|
|
297
|
+
return second ? `${first}/${second}` : null;
|
|
298
|
+
}
|
|
299
|
+
return first;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function registryUrlForPackage(
|
|
303
|
+
packageName: string,
|
|
304
|
+
result: NpmRegistryFetchResult,
|
|
305
|
+
): string | null {
|
|
306
|
+
const scope = packageName.startsWith("@")
|
|
307
|
+
? packageName.slice(0, packageName.indexOf("/"))
|
|
308
|
+
: null;
|
|
309
|
+
if (!scope) {
|
|
310
|
+
return result.config.default?.url ?? "https://registry.npmjs.org/";
|
|
311
|
+
}
|
|
312
|
+
// npm falls back to the default registry for scoped packages without an
|
|
313
|
+
// explicit scope registry, so mirror that resolution order here.
|
|
314
|
+
return (
|
|
315
|
+
(scope ? result.config.scopes?.[scope]?.url : undefined) ??
|
|
316
|
+
result.config.default?.url ??
|
|
317
|
+
null
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function lockfileResolvesThroughRegistry(
|
|
322
|
+
raw: string | null,
|
|
323
|
+
result: NpmRegistryFetchResult,
|
|
324
|
+
logger: Logger,
|
|
325
|
+
): boolean {
|
|
326
|
+
if (!raw) return false;
|
|
327
|
+
try {
|
|
328
|
+
const parsed = JSON.parse(raw) as { packages?: Record<string, unknown> };
|
|
329
|
+
if (!parsed.packages || typeof parsed.packages !== "object") return false;
|
|
330
|
+
|
|
331
|
+
let checked = 0;
|
|
332
|
+
for (const [lockfilePath, entry] of Object.entries(parsed.packages)) {
|
|
333
|
+
if (!entry || typeof entry !== "object") continue;
|
|
334
|
+
const resolved = (entry as { resolved?: unknown }).resolved;
|
|
335
|
+
if (typeof resolved !== "string" || !resolved.startsWith("http")) {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const packageName = packageNameFromLockfilePath(lockfilePath);
|
|
339
|
+
if (!packageName) continue;
|
|
340
|
+
const registryUrl = registryUrlForPackage(packageName, result);
|
|
341
|
+
if (!registryUrl || !registryUrlMatches(resolved, registryUrl)) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
checked += 1;
|
|
345
|
+
}
|
|
346
|
+
// No HTTP resolved URLs means there is no registry proof; fall back to npm.
|
|
347
|
+
return checked > 0;
|
|
348
|
+
} catch (error) {
|
|
349
|
+
logger.warn(
|
|
350
|
+
"Failed to parse package-lock.json for private registry skip check; falling back to install",
|
|
351
|
+
getErrorMeta(error),
|
|
352
|
+
);
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function lockfileProvesSafeToSkipRegistryInstall(
|
|
358
|
+
packageLock: string | null,
|
|
359
|
+
installedPackageLock: string | null,
|
|
360
|
+
result: NpmRegistryFetchResult,
|
|
361
|
+
logger: Logger,
|
|
362
|
+
): boolean {
|
|
363
|
+
if (!packageLock || !installedPackageLock) return false;
|
|
364
|
+
// Structural equality, including integrity hashes, proves node_modules was
|
|
365
|
+
// installed from the same package graph. The root lockfile's resolved URLs
|
|
366
|
+
// prove future installs will use the fetched registry config, including a
|
|
367
|
+
// stale last-known-good config while the registry service is unavailable.
|
|
368
|
+
return (
|
|
369
|
+
lockfileComparisonKey(packageLock) ===
|
|
370
|
+
lockfileComparisonKey(installedPackageLock) &&
|
|
371
|
+
lockfileResolvesThroughRegistry(packageLock, result, logger)
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function privateRegistryValidationResult(
|
|
266
376
|
npmRegistryClient: NpmRegistryClient | undefined,
|
|
267
377
|
logger: Logger,
|
|
268
|
-
): Promise<
|
|
378
|
+
): Promise<NpmRegistryFetchResult | null> {
|
|
269
379
|
if (!npmRegistryClient) {
|
|
270
|
-
return
|
|
380
|
+
return null;
|
|
271
381
|
}
|
|
272
382
|
|
|
273
383
|
try {
|
|
274
384
|
const result: NpmRegistryFetchResult = await npmRegistryClient.getConfig();
|
|
275
385
|
// `configured` / `stale` → the org is known to route installs through a
|
|
276
386
|
// registry (`stale` = last-known-good config during a config-service
|
|
277
|
-
// outage), so
|
|
278
|
-
//
|
|
279
|
-
// (APPS-4300), npm re-resolves the entire baked tree through that
|
|
280
|
-
// registry, surfacing missing packages at app-creation time instead of
|
|
281
|
-
// at deploy-time bundle build (APPS-4527).
|
|
387
|
+
// outage), so any startup install that is already required should also
|
|
388
|
+
// validate through that registry.
|
|
282
389
|
//
|
|
283
390
|
// `not-configured` and `unreachable` deliberately do NOT force the
|
|
284
391
|
// install. Not-configured orgs gain nothing from re-resolving against
|
|
@@ -289,13 +396,15 @@ async function shouldValidatePrivateRegistryInstall(
|
|
|
289
396
|
// the wrong host and writing its URLs back into the lockfile. Fail
|
|
290
397
|
// closed instead (same principle as the AppShell short-circuit,
|
|
291
398
|
// APPS-4370): skip validation and keep today's snapshot decision.
|
|
292
|
-
return result.source === "configured" || result.source === "stale"
|
|
399
|
+
return result.source === "configured" || result.source === "stale"
|
|
400
|
+
? result
|
|
401
|
+
: null;
|
|
293
402
|
} catch (err) {
|
|
294
403
|
logger.warn(
|
|
295
404
|
"Could not resolve npm registry config for startup install validation; preserving package snapshot decision",
|
|
296
405
|
getErrorMeta(err),
|
|
297
406
|
);
|
|
298
|
-
return
|
|
407
|
+
return null;
|
|
299
408
|
}
|
|
300
409
|
}
|
|
301
410
|
|
|
@@ -979,12 +1088,10 @@ export async function dev(options: {
|
|
|
979
1088
|
async (span) => {
|
|
980
1089
|
try {
|
|
981
1090
|
return await Promise.all([
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
logger,
|
|
987
|
-
),
|
|
1091
|
+
sdk.dbfsGetApplicationDirectoryHash({
|
|
1092
|
+
applicationId: applicationConfig.id,
|
|
1093
|
+
branch: activeDbfsBranchName,
|
|
1094
|
+
}),
|
|
988
1095
|
sdk.fetchCurrentUser(),
|
|
989
1096
|
]);
|
|
990
1097
|
} finally {
|
|
@@ -1039,7 +1146,6 @@ export async function dev(options: {
|
|
|
1039
1146
|
logger,
|
|
1040
1147
|
tokenManager,
|
|
1041
1148
|
features: AiServiceFeatureFlags.create(flagBootstrap),
|
|
1042
|
-
flagBootstrap,
|
|
1043
1149
|
pluginExecutionVersions:
|
|
1044
1150
|
currentUser.organizations[0].pluginExecutionVersions,
|
|
1045
1151
|
});
|
|
@@ -1055,10 +1161,6 @@ export async function dev(options: {
|
|
|
1055
1161
|
},
|
|
1056
1162
|
);
|
|
1057
1163
|
|
|
1058
|
-
if (uploadFirst) {
|
|
1059
|
-
aiService?.notifyMigrationAppArtifactsReady();
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
1164
|
logger.info(
|
|
1063
1165
|
`[dev-startup] Starting git sync (${downloadFirst ? "download" : "upload"}-first) on branch '${activeDbfsBranchName}'`,
|
|
1064
1166
|
);
|
|
@@ -1250,20 +1352,32 @@ export async function dev(options: {
|
|
|
1250
1352
|
logger.info("[dev-startup] Skipping download, already in sync");
|
|
1251
1353
|
}
|
|
1252
1354
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1355
|
+
const npmRegistryClient = aiService?.getNpmRegistryClient();
|
|
1356
|
+
const registryValidationResult =
|
|
1357
|
+
await privateRegistryValidationResult(npmRegistryClient, logger);
|
|
1358
|
+
const packageLockBeforeResolvedStrip = await readPackageLock(cwd);
|
|
1359
|
+
const installedPackageLockBeforeResolvedStrip =
|
|
1360
|
+
await readInstalledPackageLock(cwd);
|
|
1361
|
+
const lockfileAlreadyResolvesThroughRegistry =
|
|
1362
|
+
!!registryValidationResult &&
|
|
1363
|
+
lockfileProvesSafeToSkipRegistryInstall(
|
|
1364
|
+
packageLockBeforeResolvedStrip,
|
|
1365
|
+
installedPackageLockBeforeResolvedStrip,
|
|
1366
|
+
registryValidationResult,
|
|
1367
|
+
logger,
|
|
1368
|
+
);
|
|
1256
1369
|
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1259
|
-
//
|
|
1260
|
-
//
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1370
|
+
// Lockfile sanitation (APPS-4300): strip cross-registry `resolved`
|
|
1371
|
+
// URLs unless the lockfile already proves it resolves through the
|
|
1372
|
+
// active private registry. npm honors `resolved` verbatim;
|
|
1373
|
+
// stripping forces re-resolution through the current `.npmrc`.
|
|
1374
|
+
if (!lockfileAlreadyResolvesThroughRegistry) {
|
|
1375
|
+
await stripResolvedFromLockfile(cwd);
|
|
1376
|
+
} else {
|
|
1377
|
+
logger.info(
|
|
1378
|
+
"Skipping lockfile resolved-URL strip: lockfile already proves private registry resolution",
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1267
1381
|
|
|
1268
1382
|
// Materialise `~/.superblocks/npmrc` from the server-fetched
|
|
1269
1383
|
// per-org npm registry config BEFORE the global Superblocks CLI
|
|
@@ -1279,7 +1393,7 @@ export async function dev(options: {
|
|
|
1279
1393
|
} else {
|
|
1280
1394
|
try {
|
|
1281
1395
|
await syncHomeNpmrc({
|
|
1282
|
-
npmRegistryClient
|
|
1396
|
+
npmRegistryClient,
|
|
1283
1397
|
logger,
|
|
1284
1398
|
orgId: currentUser.user.currentOrganizationId,
|
|
1285
1399
|
});
|
|
@@ -1374,32 +1488,43 @@ export async function dev(options: {
|
|
|
1374
1488
|
if (!packageJsonBefore && packageJsonRequiresInstall) {
|
|
1375
1489
|
logger.info("package.json was created, installing packages…");
|
|
1376
1490
|
}
|
|
1377
|
-
const npmRegistryClient = aiService?.getNpmRegistryClient();
|
|
1378
1491
|
const forcePackageInstallRequested = !!options.forcePackageInstall;
|
|
1379
1492
|
let forcePackageInstall = forcePackageInstallRequested;
|
|
1380
|
-
const privateRegistryRequiresInstallValidation =
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1493
|
+
const privateRegistryRequiresInstallValidation =
|
|
1494
|
+
!!registryValidationResult && !!packageJsonAfter;
|
|
1495
|
+
let packageLockBeforeInstall: string | null = null;
|
|
1496
|
+
if (privateRegistryRequiresInstallValidation) {
|
|
1497
|
+
packageLockBeforeInstall = await readPackageLock(cwd);
|
|
1498
|
+
}
|
|
1386
1499
|
if (
|
|
1387
1500
|
forcePackageInstallRequested &&
|
|
1388
1501
|
hasPackageJsonSnapshotBeforeRestore
|
|
1389
1502
|
) {
|
|
1390
|
-
forcePackageInstall =
|
|
1391
|
-
packageJsonRequiresInstall ||
|
|
1392
|
-
privateRegistryRequiresInstallValidation;
|
|
1503
|
+
forcePackageInstall = packageJsonRequiresInstall;
|
|
1393
1504
|
}
|
|
1394
1505
|
|
|
1506
|
+
// Run the verification install when EITHER the package.json
|
|
1507
|
+
// requires it, upgrades just modified package.json/lockfile
|
|
1508
|
+
// (re-syncing node_modules), or the caller forced it. Private
|
|
1509
|
+
// registry validation piggybacks on those installs rather than
|
|
1510
|
+
// forcing a no-op refresh to wait for npm.
|
|
1511
|
+
const packageInstallRequired =
|
|
1512
|
+
packageJsonRequiresInstall ||
|
|
1513
|
+
upgradePromises.length > 0 ||
|
|
1514
|
+
forcePackageInstall ||
|
|
1515
|
+
(privateRegistryRequiresInstallValidation &&
|
|
1516
|
+
!lockfileAlreadyResolvesThroughRegistry);
|
|
1517
|
+
|
|
1395
1518
|
logger.info("Package install decision", {
|
|
1396
1519
|
packageJsonBeforePresent: !!packageJsonBefore,
|
|
1397
1520
|
packageJsonAfterPresent: !!packageJsonAfter,
|
|
1398
1521
|
hasPackageChanged,
|
|
1399
1522
|
packageJsonRequiresInstall,
|
|
1523
|
+
packageInstallRequired,
|
|
1400
1524
|
forcePackageInstall,
|
|
1401
1525
|
forcePackageInstallRequested,
|
|
1402
1526
|
privateRegistryRequiresInstallValidation,
|
|
1527
|
+
lockfileAlreadyResolvesThroughRegistry,
|
|
1403
1528
|
upgradePromiseCount: upgradePromises.length,
|
|
1404
1529
|
packageJsonSnapshotBefore: packageJsonSnapshotDiagnostic(
|
|
1405
1530
|
packageJsonSnapshotBefore,
|
|
@@ -1442,18 +1567,7 @@ export async function dev(options: {
|
|
|
1442
1567
|
});
|
|
1443
1568
|
}
|
|
1444
1569
|
|
|
1445
|
-
|
|
1446
|
-
// requires it, upgrades just modified package.json/lockfile
|
|
1447
|
-
// (re-syncing node_modules), the caller forced it, or a custom
|
|
1448
|
-
// registry must validate that the required packages resolve there.
|
|
1449
|
-
let packageLockBeforeInstall: string | null = null;
|
|
1450
|
-
if (
|
|
1451
|
-
packageJsonRequiresInstall ||
|
|
1452
|
-
upgradePromises.length > 0 ||
|
|
1453
|
-
forcePackageInstall ||
|
|
1454
|
-
privateRegistryRequiresInstallValidation
|
|
1455
|
-
) {
|
|
1456
|
-
packageLockBeforeInstall = await readPackageLock(cwd);
|
|
1570
|
+
if (packageInstallRequired) {
|
|
1457
1571
|
// Launch the install while the upload/restart decisions below
|
|
1458
1572
|
// are still being evaluated. The synchronous joins at upload,
|
|
1459
1573
|
// CLI restart, and pre-Vite-startup all observe and surface
|
|
@@ -1493,7 +1607,8 @@ export async function dev(options: {
|
|
|
1493
1607
|
if (
|
|
1494
1608
|
shouldUploadPackageState ||
|
|
1495
1609
|
uploadFirst ||
|
|
1496
|
-
privateRegistryRequiresInstallValidation
|
|
1610
|
+
(privateRegistryRequiresInstallValidation &&
|
|
1611
|
+
packageInstallRequired)
|
|
1497
1612
|
) {
|
|
1498
1613
|
// Upload serializes the post-install lockfile + node_modules
|
|
1499
1614
|
// tree to DBFS, so it must observe quiesced upgrade+install.
|
|
@@ -2126,34 +2241,6 @@ async function ensureLiveBranchCheckedOutAfterFetch(
|
|
|
2126
2241
|
}
|
|
2127
2242
|
}
|
|
2128
2243
|
|
|
2129
|
-
async function getDraftOrLiveEditHash(
|
|
2130
|
-
sdk: SuperblocksSdk,
|
|
2131
|
-
applicationId: string,
|
|
2132
|
-
branchName: string,
|
|
2133
|
-
logger: Logger,
|
|
2134
|
-
): Promise<string> {
|
|
2135
|
-
try {
|
|
2136
|
-
const draftHash = await sdk.dbfsGetApplicationDirectoryHash({
|
|
2137
|
-
applicationId,
|
|
2138
|
-
branch: branchName,
|
|
2139
|
-
});
|
|
2140
|
-
return draftHash;
|
|
2141
|
-
} catch (e: unknown) {
|
|
2142
|
-
if (e instanceof NotFoundError) {
|
|
2143
|
-
const liveEditHash = await sdk.dbfsGetApplicationDirectoryHash({
|
|
2144
|
-
applicationId,
|
|
2145
|
-
branch: branchName,
|
|
2146
|
-
});
|
|
2147
|
-
logger.warn(
|
|
2148
|
-
"Draft state not found, using live edit hash: " + liveEditHash,
|
|
2149
|
-
);
|
|
2150
|
-
return liveEditHash;
|
|
2151
|
-
} else {
|
|
2152
|
-
throw e;
|
|
2153
|
-
}
|
|
2154
|
-
}
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
2244
|
async function ensureRuntimeDbfsBranchConsistency({
|
|
2158
2245
|
sdk,
|
|
2159
2246
|
applicationConfig,
|
package/src/dbfs/client.test.ts
CHANGED
|
@@ -82,12 +82,115 @@ describe("getApplicationDirectoryHash", () => {
|
|
|
82
82
|
expect(rpcClient.close).toHaveBeenCalledOnce();
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
// The cold-start restore must prefer the live-edit working copy, which can have
|
|
86
|
+
// advanced past every commit via Clark's interim autosaves. This mirrors the
|
|
87
|
+
// vite-plugin-file-sync copy of getApplicationDirectoryHash (see download.ts).
|
|
88
|
+
// APPS-4873.
|
|
89
|
+
it("prefers the live-edit working copy over the draft/live commit views", async () => {
|
|
90
|
+
directoryContentsGet.mockResolvedValue("request");
|
|
91
|
+
unwrapResponseDto.mockResolvedValueOnce({ hash: "live-edit-hash" });
|
|
92
|
+
|
|
93
|
+
await expect(
|
|
94
|
+
getApplicationDirectoryHash(
|
|
95
|
+
"token",
|
|
96
|
+
"https://app.superblocks.com",
|
|
97
|
+
"app-123",
|
|
98
|
+
"main",
|
|
99
|
+
),
|
|
100
|
+
).resolves.toBe("live-edit-hash");
|
|
101
|
+
|
|
102
|
+
expect(directoryContentsGet).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(directoryContentsGet).toHaveBeenCalledWith({
|
|
104
|
+
applicationId: "app-123",
|
|
105
|
+
branchName: "main",
|
|
106
|
+
viewMode: ExportViewMode.EXPORT_LIVE_EDIT,
|
|
107
|
+
});
|
|
108
|
+
expect(warn).not.toHaveBeenCalled();
|
|
109
|
+
expect(rpcClient.close).toHaveBeenCalledOnce();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("falls back to draft/live when the app has no live-edit working copy", async () => {
|
|
113
|
+
directoryContentsGet.mockResolvedValue("request");
|
|
114
|
+
unwrapResponseDto
|
|
115
|
+
.mockRejectedValueOnce(
|
|
116
|
+
new NotFoundError(
|
|
117
|
+
"Application live-edit directory contents hash not found.",
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
.mockResolvedValueOnce({ hash: "draft-hash" });
|
|
121
|
+
|
|
122
|
+
await expect(
|
|
123
|
+
getApplicationDirectoryHash(
|
|
124
|
+
"token",
|
|
125
|
+
"https://app.superblocks.com",
|
|
126
|
+
"app-123",
|
|
127
|
+
"main",
|
|
128
|
+
),
|
|
129
|
+
).resolves.toBe("draft-hash");
|
|
130
|
+
|
|
131
|
+
expect(directoryContentsGet).toHaveBeenNthCalledWith(1, {
|
|
132
|
+
applicationId: "app-123",
|
|
133
|
+
branchName: "main",
|
|
134
|
+
viewMode: ExportViewMode.EXPORT_LIVE_EDIT,
|
|
135
|
+
});
|
|
136
|
+
expect(directoryContentsGet).toHaveBeenNthCalledWith(2, {
|
|
137
|
+
applicationId: "app-123",
|
|
138
|
+
branchName: "main",
|
|
139
|
+
viewMode: ExportViewMode.EXPORT_DRAFT,
|
|
140
|
+
});
|
|
141
|
+
expect(warn).toHaveBeenCalledWith(
|
|
142
|
+
expect.stringContaining("falling back to draft/live views"),
|
|
143
|
+
);
|
|
144
|
+
expect(rpcClient.close).toHaveBeenCalledOnce();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("falls back to draft/live against a server that predates the live-edit view mode", async () => {
|
|
148
|
+
directoryContentsGet.mockResolvedValue("request");
|
|
89
149
|
unwrapResponseDto
|
|
90
|
-
.mockRejectedValueOnce(
|
|
150
|
+
.mockRejectedValueOnce(
|
|
151
|
+
new Error("Export view mode export-live-edit is not implemented."),
|
|
152
|
+
)
|
|
153
|
+
.mockResolvedValueOnce({ hash: "draft-hash" });
|
|
154
|
+
|
|
155
|
+
await expect(
|
|
156
|
+
getApplicationDirectoryHash(
|
|
157
|
+
"token",
|
|
158
|
+
"https://app.superblocks.com",
|
|
159
|
+
"app-123",
|
|
160
|
+
"main",
|
|
161
|
+
),
|
|
162
|
+
).resolves.toBe("draft-hash");
|
|
163
|
+
|
|
164
|
+
expect(directoryContentsGet).toHaveBeenNthCalledWith(2, {
|
|
165
|
+
applicationId: "app-123",
|
|
166
|
+
branchName: "main",
|
|
167
|
+
viewMode: ExportViewMode.EXPORT_DRAFT,
|
|
168
|
+
});
|
|
169
|
+
expect(rpcClient.close).toHaveBeenCalledOnce();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("propagates an unexpected live-edit error instead of silently restoring a stale tree", async () => {
|
|
173
|
+
directoryContentsGet.mockResolvedValue("request");
|
|
174
|
+
unwrapResponseDto.mockRejectedValueOnce(new Error("unexpected boom"));
|
|
175
|
+
|
|
176
|
+
await expect(
|
|
177
|
+
getApplicationDirectoryHash(
|
|
178
|
+
"token",
|
|
179
|
+
"https://app.superblocks.com",
|
|
180
|
+
"app-123",
|
|
181
|
+
"main",
|
|
182
|
+
),
|
|
183
|
+
).rejects.toThrow("unexpected boom");
|
|
184
|
+
|
|
185
|
+
expect(directoryContentsGet).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(rpcClient.close).toHaveBeenCalledOnce();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("falls back from live-edit to draft to live when neither working copy nor draft exists", async () => {
|
|
190
|
+
directoryContentsGet.mockResolvedValue("request");
|
|
191
|
+
unwrapResponseDto
|
|
192
|
+
.mockRejectedValueOnce(new NotFoundError("no live-edit working copy"))
|
|
193
|
+
.mockRejectedValueOnce(new NotFoundError("no draft"))
|
|
91
194
|
.mockResolvedValueOnce({ hash: "live-hash" });
|
|
92
195
|
|
|
93
196
|
await expect(
|
|
@@ -102,15 +205,20 @@ describe("getApplicationDirectoryHash", () => {
|
|
|
102
205
|
expect(directoryContentsGet).toHaveBeenNthCalledWith(1, {
|
|
103
206
|
applicationId: "app-123",
|
|
104
207
|
branchName: "main",
|
|
105
|
-
viewMode: ExportViewMode.
|
|
208
|
+
viewMode: ExportViewMode.EXPORT_LIVE_EDIT,
|
|
106
209
|
});
|
|
107
210
|
expect(directoryContentsGet).toHaveBeenNthCalledWith(2, {
|
|
211
|
+
applicationId: "app-123",
|
|
212
|
+
branchName: "main",
|
|
213
|
+
viewMode: ExportViewMode.EXPORT_DRAFT,
|
|
214
|
+
});
|
|
215
|
+
expect(directoryContentsGet).toHaveBeenNthCalledWith(3, {
|
|
108
216
|
applicationId: "app-123",
|
|
109
217
|
branchName: "main",
|
|
110
218
|
viewMode: ExportViewMode.EXPORT_LIVE,
|
|
111
219
|
});
|
|
112
220
|
expect(warn).toHaveBeenCalledWith(
|
|
113
|
-
"
|
|
221
|
+
expect.stringContaining("falling back to the latest live commit"),
|
|
114
222
|
);
|
|
115
223
|
expect(rpcClient.close).toHaveBeenCalledOnce();
|
|
116
224
|
});
|
package/src/dbfs/client.ts
CHANGED
|
@@ -39,44 +39,98 @@ export async function getApplicationDirectoryHash(
|
|
|
39
39
|
}
|
|
40
40
|
return commitHash;
|
|
41
41
|
}
|
|
42
|
+
// Prefer the live-edit working copy. Clark's in-progress autosaves advance
|
|
43
|
+
// the working-copy pointer without creating a commit, so the commit-based
|
|
44
|
+
// draft/live views can lag behind it. Restoring an older commit on a
|
|
45
|
+
// cold/recycled pod is what made in-progress work appear lost (APPS-4873).
|
|
46
|
+
// Mirrors the vite-plugin-file-sync copy in download.ts.
|
|
42
47
|
try {
|
|
43
48
|
const response = await unwrapResponseDto(
|
|
44
49
|
rpcClient.call.v3.application.directoryContents.get({
|
|
45
50
|
applicationId,
|
|
46
51
|
branchName: branch,
|
|
47
|
-
viewMode: ExportViewMode.
|
|
52
|
+
viewMode: ExportViewMode.EXPORT_LIVE_EDIT,
|
|
48
53
|
}),
|
|
49
54
|
);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
throw new Error(
|
|
53
|
-
`No directory contents hash found for the view mode ${ExportViewMode.EXPORT_DRAFT}`,
|
|
54
|
-
);
|
|
55
|
+
if (response.hash) {
|
|
56
|
+
return response.hash;
|
|
55
57
|
}
|
|
56
|
-
return liveEditHash;
|
|
57
58
|
} catch (e) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
// Fall back to the commit-based draft/live views only when this app has no
|
|
60
|
+
// live-edit working copy (NotFoundError) or the server predates the
|
|
61
|
+
// EXPORT_LIVE_EDIT view mode (deploy skew). Any other error surfaces.
|
|
62
|
+
if (!isMissingLiveEditOrUnsupportedViewMode(e)) {
|
|
63
|
+
throw e;
|
|
64
|
+
}
|
|
65
|
+
if (!options?.silent) {
|
|
66
|
+
getLogger().warn(
|
|
67
|
+
`Live-edit working copy unavailable (${
|
|
68
|
+
e instanceof Error ? e.message : String(e)
|
|
69
|
+
}), falling back to draft/live views`,
|
|
65
70
|
);
|
|
66
|
-
if (!options?.silent) {
|
|
67
|
-
getLogger().warn(
|
|
68
|
-
`Draft state not found, using live edit hash: ${response.hash}`,
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
return response.hash;
|
|
72
71
|
}
|
|
73
|
-
throw e;
|
|
74
72
|
}
|
|
73
|
+
return getDraftOrLiveCommitHash(rpcClient, applicationId, branch, options);
|
|
75
74
|
} finally {
|
|
76
75
|
rpcClient.close();
|
|
77
76
|
}
|
|
78
77
|
}
|
|
79
78
|
|
|
79
|
+
// The legacy restore path: the latest draft commit, falling back to the latest
|
|
80
|
+
// non-draft (live) commit. Used when no live-edit working copy is available, or
|
|
81
|
+
// against a server that predates the live-edit view mode.
|
|
82
|
+
async function getDraftOrLiveCommitHash(
|
|
83
|
+
rpcClient: Awaited<ReturnType<typeof connectToISocketRPCServer>>,
|
|
84
|
+
applicationId: string,
|
|
85
|
+
branch: string | undefined,
|
|
86
|
+
options?: { silent?: boolean },
|
|
87
|
+
): Promise<string> {
|
|
88
|
+
try {
|
|
89
|
+
const response = await unwrapResponseDto(
|
|
90
|
+
rpcClient.call.v3.application.directoryContents.get({
|
|
91
|
+
applicationId,
|
|
92
|
+
branchName: branch,
|
|
93
|
+
viewMode: ExportViewMode.EXPORT_DRAFT,
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
const hash = response.hash;
|
|
97
|
+
if (!hash) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`No directory contents hash found for the view mode ${ExportViewMode.EXPORT_DRAFT}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return hash;
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (e instanceof NotFoundError) {
|
|
105
|
+
const response = await unwrapResponseDto(
|
|
106
|
+
rpcClient.call.v3.application.directoryContents.get({
|
|
107
|
+
applicationId,
|
|
108
|
+
branchName: branch,
|
|
109
|
+
viewMode: ExportViewMode.EXPORT_LIVE,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
if (!options?.silent) {
|
|
113
|
+
getLogger().warn(
|
|
114
|
+
`Draft commit not found, falling back to the latest live commit: ${response.hash}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return response.hash;
|
|
118
|
+
}
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// True when the live-edit view should yield to the legacy draft/live path:
|
|
124
|
+
// either this app has no live-edit working copy (NotFoundError), or the server
|
|
125
|
+
// is too old to know the view mode and reports it as not implemented.
|
|
126
|
+
function isMissingLiveEditOrUnsupportedViewMode(e: unknown): boolean {
|
|
127
|
+
if (e instanceof NotFoundError) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
131
|
+
return message.includes("is not implemented");
|
|
132
|
+
}
|
|
133
|
+
|
|
80
134
|
export async function downloadApplicationDirectory(
|
|
81
135
|
token: string,
|
|
82
136
|
superblocksBaseUrl: string,
|
|
@@ -819,9 +819,9 @@ export async function createDevServer({
|
|
|
819
819
|
}
|
|
820
820
|
|
|
821
821
|
try {
|
|
822
|
-
const payload = (await jwtVerifier.verify<{ scope: string }>(
|
|
823
|
-
|
|
824
|
-
)) as SuperblocksScopedJwtPayload;
|
|
822
|
+
const payload = (await jwtVerifier.verify<{ scope: string }>(token, {
|
|
823
|
+
algorithms: ["ES256"],
|
|
824
|
+
})) as SuperblocksScopedJwtPayload;
|
|
825
825
|
|
|
826
826
|
const scopes = payload.scope?.split(" ") ?? [];
|
|
827
827
|
if (!scopes.includes(EDIT_APPLICATION_SCOPE)) {
|