@sasjs/cli 6.30.0 → 6.30.1

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.
@@ -566,11 +566,77 @@ const DIRS = new Set(['/'])
566
566
  // multi-megabyte seeded file does not scan it again.
567
567
  const SIZES = new Map()
568
568
 
569
+ // ------------------------------------------------------------- sharded drive
570
+ /**
571
+ * The pieces a captured drive ships in.
572
+ *
573
+ * A site that ships its drive as one file makes every visitor download all of
574
+ * it before the first page can render - on a demonstration site that is every
575
+ * app's bundle behind a list of tiles. The build splits the capture instead: one
576
+ * small boot shard with the files a first paint needs, and a shard per large
577
+ * folder or file, fetched when something reads a path in it.
578
+ *
579
+ * `PENDING` is what makes that invisible to everything above: the paths, folders
580
+ * and sizes of the files not yet in memory are known from the index, so a
581
+ * listing, a stat and an existence check answer exactly as they would with the
582
+ * contents present. Only a read has to fail, because it cannot wait - the
583
+ * request that reads preloads the piece first (see `ensurePaths`), and the code
584
+ * that reads without asking gets a refusal it can retry on rather than an empty
585
+ * file.
586
+ */
587
+ const PENDING = new Map() // abs path -> { shard, bytes }
588
+ const SHARDS = new Map() // shard id -> { file, state, promise }
589
+ // paths the visitor deleted, and which the page hands back at boot. A sharded
590
+ // drive cannot rely on absence any more: the index lists every file the site
591
+ // ships, so a capture file deleted before a reload would return with the next
592
+ // index. A tombstone is what keeps it deleted.
593
+ const DELETED = new Set()
594
+ let bootShard = 0
595
+ // how many pieces this build split its capture into, from mock-config.json: 0
596
+ // (or absent) means an unsharded build, whose drive arrives whole
597
+ let SHARD_COUNT = 0
598
+ // whether the config was actually read. A config that failed to load says nothing
599
+ // about the capture, and treating it as "unsharded" would restore a drive of the
600
+ // visitor's changed files only - every file they had never loaded then reads
601
+ // ENOENT with nothing logged - so the two states are kept apart.
602
+ let CONFIG_LOADED = false
603
+
604
+ /** The refusal a read of a path that is still on the site raises. */
605
+ const PENDING_CODE = 'EDRIVEPENDING'
606
+
607
+ const pendingError = (p) => {
608
+ const err = new Error(
609
+ `The drive file '${String(p)}' is in a shard this host has not fetched yet`
610
+ )
611
+ err.code = PENDING_CODE
612
+ // the path, so the host can fetch that piece rather than the whole drive
613
+ err.drivePath = P.resolve(String(p))
614
+ err.path = err.drivePath
615
+ return err
616
+ }
617
+
618
+ /**
619
+ * Puts contents at a path.
620
+ *
621
+ * One function for every write, because a write has to do three things that are
622
+ * easy to do in two: store the value, drop the cached size, and take the path out
623
+ * of the shard index. A file whose bytes are still on the site and whose contents
624
+ * are also in memory is listed twice and counted twice, and a piece fetched later
625
+ * would put the capture's copy back over the visitor's.
626
+ */
627
+ const putEntry = (abs, contents) => {
628
+ FILES.set(abs, contents)
629
+ SIZES.delete(abs)
630
+ PENDING.delete(abs)
631
+ }
632
+
569
633
  /** The byte length of an entry on the drive, measured once per stored value. */
570
634
  const entrySizeAt = (abs) => {
571
635
  const contents = FILES.get(abs)
572
636
  const cached = SIZES.get(abs)
573
637
  if (cached && cached.contents === contents) return cached.size
638
+ const pending = PENDING.get(abs)
639
+ if (contents === undefined && pending) return pending.bytes
574
640
  const size = entrySize(contents)
575
641
  SIZES.set(abs, { contents, size })
576
642
  return size
@@ -585,6 +651,11 @@ const dirty = new Map() // abs path -> contents, or null when deleted
585
651
 
586
652
  const markDirty = (path) => {
587
653
  const contents = FILES.has(path) ? FILES.get(path) : null
654
+ // one place decides what a deletion means, because every write and every
655
+ // remove passes through here: a file that is gone is tombstoned, and writing
656
+ // it again clears the tombstone
657
+ if (contents === null) DELETED.add(path)
658
+ else DELETED.delete(path)
588
659
  dirty.set(path, contents)
589
660
  }
590
661
 
@@ -601,25 +672,150 @@ const dumpAll = () => {
601
672
  return out
602
673
  }
603
674
 
604
- const loadSnapshot = (files) => {
675
+ /**
676
+ * Puts a drive in memory: the files that arrive with it, and the index of the
677
+ * pieces that did not.
678
+ *
679
+ * One function for both paths - a site's own snapshot and a drive the page
680
+ * restored - because the index has to be applied either way: it is what makes a
681
+ * file that is still on the site exist for a listing and a stat, and it is what
682
+ * a read goes back for. Files handed in here are contents in hand, so they win
683
+ * over the index and their shard is never fetched.
684
+ */
685
+ const installDrive = (index, files, deleted) => {
605
686
  FILES.clear()
606
687
  SIZES.clear()
607
688
  DIRS.clear()
689
+ PENDING.clear()
690
+ SHARDS.clear()
691
+ DELETED.clear()
692
+ bootShard = 0
608
693
  DIRS.add('/')
609
694
  mkdirp(DRIVE_APP)
610
695
  mkdirp(`${DRIVE_ROOT}/files`)
696
+ for (const path of deleted || []) DELETED.add(P.resolve(String(path)))
697
+ if (index) applyIndex(index)
611
698
  for (const [path, contents] of Object.entries(files || {})) {
612
699
  if (typeof contents !== 'string') continue
613
- FILES.set(P.resolve(path), contents)
614
- mkdirp(P.dirname(path))
700
+ const abs = P.resolve(path)
701
+ FILES.set(abs, contents)
702
+ PENDING.delete(abs)
703
+ SIZES.delete(abs)
704
+ mkdirp(P.dirname(abs))
615
705
  }
616
- lastBoot = {
617
- booted: true,
618
- restored: true,
619
- files: FILES.size,
620
- at: new Date().toISOString()
706
+ }
707
+
708
+ /**
709
+ * Records the shards a drive ships in and the entry each file belongs to.
710
+ *
711
+ * Nothing is fetched here: the paths, folders and sizes answer for the whole
712
+ * drive from this point, so only a read has to go back for the bytes.
713
+ */
714
+ const applyIndex = (index) => {
715
+ bootShard = typeof index.boot === 'number' ? index.boot : 0
716
+ for (const shard of index.shards || []) {
717
+ if (!shard || typeof shard.file !== 'string') continue
718
+ SHARDS.set(shard.id, { file: shard.file, state: 'unloaded', promise: null })
719
+ }
720
+ for (const [path, entry] of Object.entries(index.entries || {})) {
721
+ const abs = P.resolve(path)
722
+ // a restored or written file is what the visitor has: the site's copy of it
723
+ // is not fetched over the top. And a path the visitor deleted stays deleted -
724
+ // the index knows the site still ships it, which is exactly why the
725
+ // tombstone has to win here.
726
+ if (FILES.has(abs) || DELETED.has(abs)) continue
727
+ const bytes = entry && typeof entry.bytes === 'number' ? entry.bytes : 0
728
+ PENDING.set(abs, { shard: entry.shard, bytes })
729
+ SIZES.set(abs, { contents: undefined, size: bytes })
730
+ mkdirp(P.dirname(abs))
621
731
  }
622
- return lastBoot
732
+ }
733
+
734
+ /**
735
+ * Fetches one shard into memory.
736
+ *
737
+ * Concurrent readers of the same shard share the request, and a failed one is
738
+ * left unloaded so the next read retries rather than believing the drive is
739
+ * short of a file. A file that is already in memory - a restored drive, a write
740
+ * the visitor made - is never overwritten by a shard: the drive they have is the
741
+ * one they are working on.
742
+ */
743
+ const loadShard = (id) => {
744
+ const shard = SHARDS.get(id)
745
+ if (!shard || shard.state === 'loaded') return Promise.resolve()
746
+ if (shard.promise) return shard.promise
747
+
748
+ shard.state = 'loading'
749
+ shard.promise = (async () => {
750
+ const res = await fetch(shard.file, { cache: 'no-store' })
751
+ if (!res.ok)
752
+ throw new Error(
753
+ 'The drive shard ' +
754
+ shard.file +
755
+ ' could not be read (' +
756
+ res.status +
757
+ '), so the drive is incomplete'
758
+ )
759
+ const body = await res.json()
760
+ if (body && body.format !== undefined && body.format !== DRIVE_FORMAT)
761
+ throw new Error(
762
+ 'The drive in ' +
763
+ shard.file +
764
+ ' is format ' +
765
+ JSON.stringify(body.format) +
766
+ ', and this runtime speaks format ' +
767
+ DRIVE_FORMAT +
768
+ '. Rebuild the mock site with the matching @sasjs/cli.'
769
+ )
770
+ for (const [path, contents] of Object.entries((body && body.files) || {})) {
771
+ if (typeof contents !== 'string') continue
772
+ const abs = P.resolve(path)
773
+ PENDING.delete(abs)
774
+ // a deleted file stays deleted, even when the piece holding it is fetched
775
+ if (FILES.has(abs) || DELETED.has(abs)) continue
776
+ FILES.set(abs, contents)
777
+ SIZES.delete(abs)
778
+ mkdirp(P.dirname(abs))
779
+ }
780
+ shard.state = 'loaded'
781
+ return true
782
+ })().catch((e) => {
783
+ shard.state = 'unloaded'
784
+ shard.promise = null
785
+ throw e
786
+ })
787
+
788
+ return shard.promise
789
+ }
790
+
791
+ /** Fetches the pieces holding these paths, and nothing else. */
792
+ const ensurePaths = async (paths) => {
793
+ const ids = new Set()
794
+ for (const path of paths || []) {
795
+ // the API names a path under the drive (`/data/x.csv`) and a program names
796
+ // an absolute one; both resolve to an entry here
797
+ let abs = P.resolve(String(path))
798
+ if (!PENDING.has(abs)) {
799
+ try {
800
+ abs = absDrivePath(path)
801
+ } catch (e) {
802
+ continue
803
+ }
804
+ }
805
+ const pending = PENDING.get(abs)
806
+ if (pending && pending.shard !== undefined) ids.add(pending.shard)
807
+ }
808
+ if (!ids.size) return false
809
+ await Promise.all([...ids].map((id) => loadShard(id)))
810
+ return true
811
+ }
812
+
813
+ /** Fetches the rest of the drive. The answer to a read that could not wait. */
814
+ const ensureAll = async () => {
815
+ const ids = [...SHARDS.keys()]
816
+ if (!ids.length) return false
817
+ await Promise.all(ids.map((id) => loadShard(id)))
818
+ return true
623
819
  }
624
820
 
625
821
  const mkdirp = (dir) => {
@@ -653,6 +849,7 @@ const FS = {
653
849
  readFileSync(p, options) {
654
850
  const abs = P.resolve(String(p))
655
851
  if (!FILES.has(abs)) {
852
+ if (PENDING.has(abs)) throw pendingError(p)
656
853
  const err = new Error(
657
854
  `ENOENT: no such file or directory, open '${String(p)}'`
658
855
  )
@@ -683,7 +880,8 @@ const FS = {
683
880
  },
684
881
  existsSync(p) {
685
882
  const abs = P.resolve(String(p))
686
- return FILES.has(abs) || DIRS.has(abs)
883
+ // a pending entry exists: only its bytes are still on the site
884
+ return FILES.has(abs) || PENDING.has(abs) || DIRS.has(abs)
687
885
  },
688
886
  writeFileSync(p, data, options) {
689
887
  const abs = P.resolve(String(p))
@@ -693,7 +891,12 @@ const FS = {
693
891
  // file in chunks produces a whole file on the server and would otherwise
694
892
  // produce its last chunk here.
695
893
  const { encoding, flag } = writeOptions(options)
696
- const appending = flag === 'a' && FILES.has(abs)
894
+ // On a sharded drive the capture's bytes are in PENDING, not FILES, until
895
+ // something reads them: appending there would replace them with the appended
896
+ // chunk and persist that as the visitor's version. Refuse instead, so the
897
+ // host fetches the piece and replays the write the way it does for a read.
898
+ const appending = flag === 'a' && (FILES.has(abs) || PENDING.has(abs))
899
+ if (appending && !FILES.has(abs)) throw pendingError(p)
697
900
  const before = appending ? entryBytes(FILES.get(abs)) : null
698
901
  const stored = appending && isBinary(FILES.get(abs))
699
902
  // text, or the bytes node lets a service write: an array, a number or null
@@ -706,7 +909,7 @@ const FS = {
706
909
  `The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView. Received ${describeValue(data)}`
707
910
  )
708
911
  const written = appending ? joinBytes(before, bytes) : bytes
709
- FILES.set(abs, BINARY_PREFIX + bytesToBase64(written))
912
+ putEntry(abs, BINARY_PREFIX + bytesToBase64(written))
710
913
  markDirty(abs)
711
914
  return
712
915
  }
@@ -716,13 +919,13 @@ const FS = {
716
919
  // as the bytes it produces
717
920
  if (how === 'utf8' && !stored) {
718
921
  const text = appending ? entryText(FILES.get(abs)) + data : data
719
- FILES.set(abs, encodeText(text))
922
+ putEntry(abs, encodeText(text))
720
923
  markDirty(abs)
721
924
  return
722
925
  }
723
926
  const bytes = BUFFERS.from(data, how)
724
927
  const written = appending ? joinBytes(before, bytes) : bytes
725
- FILES.set(abs, BINARY_PREFIX + bytesToBase64(written))
928
+ putEntry(abs, BINARY_PREFIX + bytesToBase64(written))
726
929
  markDirty(abs)
727
930
  },
728
931
  writeFile(p, data, options, cb) {
@@ -756,7 +959,9 @@ const FS = {
756
959
  const abs = P.resolve(String(p))
757
960
  const pre = abs === '/' ? '/' : abs + '/'
758
961
  const out = new Set()
759
- for (const k of FILES.keys()) {
962
+ // both maps: a folder lists the files whose bytes are still on the site as
963
+ // well as the ones in memory, or a pending shard would look like a delete
964
+ for (const k of [...FILES.keys(), ...PENDING.keys()]) {
760
965
  if (k.startsWith(pre)) {
761
966
  const rest = k.slice(pre.length)
762
967
  if (rest && !rest.includes('/')) out.add(rest)
@@ -774,7 +979,7 @@ const FS = {
774
979
  const abs = P.resolve(String(p))
775
980
  if (DIRS.has(abs))
776
981
  return { isDirectory: () => true, isFile: () => false, size: 0 }
777
- if (FILES.has(abs)) {
982
+ if (FILES.has(abs) || PENDING.has(abs)) {
778
983
  const size = entrySizeAt(abs)
779
984
  return { isDirectory: () => false, isFile: () => true, size }
780
985
  }
@@ -788,6 +993,9 @@ const FS = {
788
993
  const abs = P.resolve(String(p))
789
994
  FILES.delete(abs)
790
995
  SIZES.delete(abs)
996
+ // a pending entry is deleted like a loaded one: the shard it came from is
997
+ // never fetched, so nothing can bring it back
998
+ PENDING.delete(abs)
791
999
  markDirty(abs)
792
1000
  },
793
1001
  copyFileSync(from, to) {
@@ -798,7 +1006,7 @@ const FS = {
798
1006
  // text file is not re-escaped
799
1007
  const abs = P.resolve(String(to))
800
1008
  mkdirp(P.dirname(abs))
801
- FILES.set(abs, FILES.get(src))
1009
+ putEntry(abs, FILES.get(src))
802
1010
  markDirty(abs)
803
1011
  }
804
1012
  }
@@ -876,6 +1084,13 @@ const loadMockConfig = async () => {
876
1084
  if (cfg && typeof cfg.seedPath === 'string' && cfg.seedPath.trim()) {
877
1085
  SEED_PATH = cfg.seedPath
878
1086
  }
1087
+ // how many pieces the capture was split into, so a restore knows whether
1088
+ // there is an index to read before it asks for one: an unsharded build (every
1089
+ // site built before the split) must not pay a request per visit for a file
1090
+ // that is not there
1091
+ if (cfg && typeof cfg.snapshotShards === 'number')
1092
+ SHARD_COUNT = cfg.snapshotShards
1093
+ CONFIG_LOADED = true
879
1094
  } catch (e) {
880
1095
  /* keep the defaults */
881
1096
  }
@@ -1198,13 +1413,12 @@ const DRIVE_FILES = () => `${DRIVE_ROOT}/files`
1198
1413
  */
1199
1414
  const storeEntry = (abs, data) => {
1200
1415
  const bytes = asBytes(data)
1201
- FILES.set(
1416
+ putEntry(
1202
1417
  abs,
1203
1418
  bytes
1204
1419
  ? BINARY_PREFIX + bytesToBase64(bytes)
1205
1420
  : encodeText(String(data === undefined || data === null ? '' : data))
1206
1421
  )
1207
- SIZES.delete(abs)
1208
1422
  markDirty(abs)
1209
1423
  }
1210
1424
 
@@ -1273,6 +1487,9 @@ const driveStore = {
1273
1487
  async read(drivePath) {
1274
1488
  const abs = absDrivePath(drivePath)
1275
1489
  const stored = FILES.get(abs)
1490
+ // a file whose bytes are still on the site is not a missing file: the code
1491
+ // lets the host fetch the piece and answer the request again
1492
+ if (stored === undefined && PENDING.has(abs)) throw pendingError(abs)
1276
1493
  if (stored === undefined) {
1277
1494
  // the API checks existence first, so this is a store that was handed a
1278
1495
  // path it does not hold - refused in the drive's own words rather than
@@ -1297,13 +1514,14 @@ const driveStore = {
1297
1514
 
1298
1515
  async exists(drivePath) {
1299
1516
  const abs = absDrivePath(drivePath)
1300
- return FILES.has(abs) || DIRS.has(abs)
1517
+ return FILES.has(abs) || PENDING.has(abs) || DIRS.has(abs)
1301
1518
  },
1302
1519
 
1303
1520
  async stat(drivePath) {
1304
1521
  const abs = absDrivePath(drivePath)
1305
1522
  if (DIRS.has(abs)) return { directory: true, size: 0 }
1306
- if (FILES.has(abs)) return { directory: false, size: entrySizeAt(abs) }
1523
+ if (FILES.has(abs) || PENDING.has(abs))
1524
+ return { directory: false, size: entrySizeAt(abs) }
1307
1525
  const err = new Error(
1308
1526
  `ENOENT: no such file or directory, stat '${drivePath}'`
1309
1527
  )
@@ -1316,7 +1534,9 @@ const driveStore = {
1316
1534
  const pre = abs === '/' ? '/' : abs + '/'
1317
1535
  const files = []
1318
1536
  const folders = []
1319
- for (const key of FILES.keys()) {
1537
+ // a pending file is listed like a loaded one: its bytes are elsewhere, not
1538
+ // its name
1539
+ for (const key of [...FILES.keys(), ...PENDING.keys()]) {
1320
1540
  if (!key.startsWith(pre)) continue
1321
1541
  const rest = key.slice(pre.length)
1322
1542
  if (rest && !rest.includes('/')) files.push(rest)
@@ -1342,17 +1562,19 @@ const driveStore = {
1342
1562
  */
1343
1563
  async remove(drivePath) {
1344
1564
  const abs = absDrivePath(drivePath)
1345
- if (FILES.has(abs)) {
1565
+ if (FILES.has(abs) || PENDING.has(abs)) {
1346
1566
  FILES.delete(abs)
1347
1567
  SIZES.delete(abs)
1568
+ PENDING.delete(abs)
1348
1569
  markDirty(abs)
1349
1570
  return
1350
1571
  }
1351
1572
  const prefix = abs + '/'
1352
- for (const path of [...FILES.keys()]) {
1573
+ for (const path of [...FILES.keys(), ...PENDING.keys()]) {
1353
1574
  if (!path.startsWith(prefix)) continue
1354
1575
  FILES.delete(path)
1355
1576
  SIZES.delete(path)
1577
+ PENDING.delete(path)
1356
1578
  markDirty(path)
1357
1579
  }
1358
1580
  for (const dir of [...DIRS]) {
@@ -1365,9 +1587,12 @@ const driveStore = {
1365
1587
  const from = absDrivePath(fromPath)
1366
1588
  const to = absDrivePath(toPath)
1367
1589
  const moveEntry = (source, target) => {
1368
- FILES.set(target, FILES.get(source))
1590
+ putEntry(target, FILES.get(source))
1369
1591
  FILES.delete(source)
1370
1592
  SIZES.delete(source)
1593
+ // the source is gone from the drive, so it cannot still be a piece of the
1594
+ // capture either
1595
+ PENDING.delete(source)
1371
1596
  mkdirp(P.dirname(target))
1372
1597
  markDirty(target)
1373
1598
  markDirty(source)
@@ -1412,8 +1637,6 @@ const fetchDrive = async () => {
1412
1637
  const res = await fetch(url, { cache: 'no-store' })
1413
1638
  if (!res.ok) continue
1414
1639
  const body = await res.json()
1415
- const files = body && body.files ? body.files : body
1416
- if (!files || typeof files !== 'object') continue
1417
1640
 
1418
1641
  // A file that declares a format this runtime does not speak is refused
1419
1642
  // rather than misread: the markers inside it would decode as text, and the
@@ -1431,11 +1654,26 @@ const fetchDrive = async () => {
1431
1654
  '. Rebuild the mock site with the matching @sasjs/cli.'
1432
1655
  )
1433
1656
 
1434
- return { source, files }
1657
+ // A sharded capture is an index rather than contents: the build splits a
1658
+ // drive so a first page does not download every app's bundle, and the
1659
+ // files arrive per shard as something reads them.
1660
+ if (body && body.sharded) {
1661
+ if (!body.entries || typeof body.entries !== 'object')
1662
+ throw new Error(
1663
+ 'The drive in ' + url + ' is sharded but carries no entry index'
1664
+ )
1665
+ return { source, files: {}, index: body }
1666
+ }
1667
+
1668
+ const files = body && body.files ? body.files : body
1669
+ if (!files || typeof files !== 'object') continue
1670
+
1671
+ return { source, files, index: null }
1435
1672
  } catch (e) {
1436
1673
  // a format mismatch is not a reason to try the other file: it is the file
1437
1674
  // the operator meant to ship, and falling through would hide it
1438
1675
  if (e && /is format /.test(e.message)) throw e
1676
+ if (e && /sharded but carries no entry index/.test(e.message)) throw e
1439
1677
  /* fall through to the next source */
1440
1678
  }
1441
1679
  }
@@ -1449,31 +1687,96 @@ const fetchDrive = async () => {
1449
1687
  )
1450
1688
  }
1451
1689
 
1690
+ /**
1691
+ * The index of a sharded drive, read even when the page hands one in.
1692
+ *
1693
+ * A restore is what this visitor has stored, and it is not the whole drive: the
1694
+ * parts of this build they never loaded are still on the site, so without the
1695
+ * index a second visit would find every app bundle missing. A site with no
1696
+ * snapshot, or an unsharded one, answers null and the restored drive stands on
1697
+ * its own.
1698
+ */
1699
+ const fetchSnapshotIndex = async () => {
1700
+ try {
1701
+ const res = await fetch(SNAPSHOT_PATH, { cache: 'no-store' })
1702
+ // 404 is the one answer that means "this site ships no snapshot". Anything
1703
+ // else - a deploy caught half-written, a truncated body, a dropped
1704
+ // connection - leaves the drive incomplete in a way nobody would see: the
1705
+ // restored drive is the visitor's changed files and every captured file they
1706
+ // never loaded reads ENOENT, with nothing logged.
1707
+ if (res.status === 404) return null
1708
+ if (!res.ok) {
1709
+ const err = new Error(
1710
+ 'The drive index at ' +
1711
+ SNAPSHOT_PATH +
1712
+ ' answered ' +
1713
+ res.status +
1714
+ ', so this drive cannot be read whole.'
1715
+ )
1716
+ // marked, so the catch below reports it as it is rather than wrapping it
1717
+ err.driveUnreadable = true
1718
+ throw err
1719
+ }
1720
+ const body = await res.json()
1721
+ if (!body || !body.sharded) return null
1722
+ if (body.format !== undefined && body.format !== DRIVE_FORMAT)
1723
+ throw new Error(
1724
+ 'The drive in ' +
1725
+ SNAPSHOT_PATH +
1726
+ ' is format ' +
1727
+ JSON.stringify(body.format) +
1728
+ ', and this runtime speaks format ' +
1729
+ DRIVE_FORMAT +
1730
+ '. Rebuild the mock site with the matching @sasjs/cli.'
1731
+ )
1732
+ return body
1733
+ } catch (e) {
1734
+ // a format mismatch and a failed read both mean the index cannot be used,
1735
+ // and both are reported: only "no snapshot here" is a normal answer
1736
+ if (e && /is format /.test(e.message)) throw e
1737
+ if (e && e.driveUnreadable) throw e
1738
+ const err = new Error(
1739
+ 'The drive index at ' +
1740
+ SNAPSHOT_PATH +
1741
+ ' could not be read: ' +
1742
+ ((e && e.message) || e)
1743
+ )
1744
+ err.driveUnreadable = true
1745
+ throw err
1746
+ }
1747
+ }
1748
+
1452
1749
  const boot = async () => {
1453
1750
  await loadMockConfig()
1454
1751
  await loadWeboutPrelude()
1455
- const { source, files: seed } = await fetchDrive()
1752
+ const { source, files: seed, index } = await fetchDrive()
1456
1753
  // a seed replaces the drive, it does not merge into one: the worker survives
1457
1754
  // page loads, so it can still hold the drive it was given last time, and any
1458
1755
  // file only that drive had would otherwise outlive the build that wrote it
1459
- FILES.clear()
1460
- SIZES.clear()
1461
- DIRS.clear()
1462
- DIRS.add('/')
1463
- mkdirp(DRIVE_APP)
1464
- mkdirp(`${DRIVE_ROOT}/files`)
1465
- for (const [path, contents] of Object.entries(seed)) {
1466
- FILES.set(P.resolve(path), contents)
1467
- mkdirp(P.dirname(path))
1468
- }
1756
+ installDrive(index, seed)
1757
+ // the boot shard holds what a first paint reads - configuration, listings, an
1758
+ // app's own small files - and everything else waits until something reads it.
1759
+ // A boot shard that does not arrive (a half-uploaded deploy) must not stop the
1760
+ // page booting: the index is still worth having, because every read then names
1761
+ // the piece it could not fetch instead of the drive quietly starting empty.
1762
+ let bootShardError = null
1763
+ if (index)
1764
+ await loadShard(bootShard).catch((e) => {
1765
+ bootShardError = (e && e.message) || String(e)
1766
+ })
1469
1767
  dirty.clear()
1470
1768
 
1471
1769
  // No bootstrap: seeding a drive is an ordinary service call, made by the app
1472
1770
  // (or an operator) the same way it would be against a server. The host knows
1473
1771
  // nothing about what a project's services do.
1474
- // Mark the whole drive dirty once, so the host persists a COMPLETE snapshot:
1475
- // later restores then need no merging with the seed, and a deleted file (a
1476
- // seeder service that removes itself) stays deleted.
1772
+ //
1773
+ // What is in memory is marked dirty once, so the page persists it: on an
1774
+ // unsharded build that is the whole drive, and on a sharded one it is the boot
1775
+ // shard. The rest is not lost by that - it is still on the site, the index says
1776
+ // so, and the page's stored drive is what it has *changed*, with the index
1777
+ // supplying the pieces it never loaded. A file deleted here is tombstoned
1778
+ // (markDirty is what does it), so the index cannot bring it back on the next
1779
+ // visit.
1477
1780
  for (const path of FILES.keys()) markDirty(path)
1478
1781
 
1479
1782
  lastBoot = {
@@ -1481,6 +1784,10 @@ const boot = async () => {
1481
1784
  restored: false,
1482
1785
  source,
1483
1786
  files: FILES.size,
1787
+ onSite: PENDING.size,
1788
+ // reported rather than thrown: the drive is usable, and every read that
1789
+ // needs the missing piece names it
1790
+ driveError: bootShardError,
1484
1791
  at: new Date().toISOString()
1485
1792
  }
1486
1793
  return lastBoot
@@ -1518,17 +1825,58 @@ const api = {
1518
1825
  return true
1519
1826
  },
1520
1827
  // Called by the host with the persisted drive; skips seeding entirely.
1521
- load: (files) => {
1828
+ load: (files, deleted) => {
1522
1829
  started = true
1523
1830
  // The site's configuration and the webout() block are host files, not drive
1524
1831
  // contents, so a restore needs them exactly as a seed does: only the seed
1525
1832
  // used to load them, and every service written with webout() then failed
1526
1833
  // with "webout is not defined" for a visitor the drive had been restored
1527
- // for. The drive itself is loaded synchronously, so callers that read it
1528
- // straight away still can; the promise is what a worker awaits.
1529
- const hostFiles = Promise.all([loadMockConfig(), loadWeboutPrelude()])
1530
- const info = loadSnapshot(files)
1531
- ready = hostFiles.then(() => info)
1834
+ // for.
1835
+ //
1836
+ // The restored drive is installed synchronously, so a caller that reads it
1837
+ // straight after this call still can; the index of what the site still holds
1838
+ // arrives with the promise a worker awaits, and so does the boot shard.
1839
+ installDrive(null, files, deleted)
1840
+ lastBoot = {
1841
+ booted: true,
1842
+ restored: true,
1843
+ files: FILES.size,
1844
+ onSite: 0,
1845
+ at: new Date().toISOString()
1846
+ }
1847
+ ready = Promise.all([loadMockConfig(), loadWeboutPrelude()]).then(
1848
+ async () => {
1849
+ // Only a build that split its capture has an index to read: probing for
1850
+ // one on every restore would cost a request on the warm path, which is
1851
+ // the path this exists to keep cheap. A config that did not load is not
1852
+ // evidence of an unsharded capture - every build writes one - so that
1853
+ // case probes, and a 404 costs one request on a site that is broken
1854
+ // anyway.
1855
+ const sharded = CONFIG_LOADED ? SHARD_COUNT > 0 : true
1856
+ // an index that fails to arrive is reported, not read as "no snapshot":
1857
+ // restoring without it leaves the visitor with only their own changed
1858
+ // files, and every captured file they had not opened reads ENOENT
1859
+ const index = sharded
1860
+ ? await fetchSnapshotIndex().catch((e) => {
1861
+ lastBoot.driveError = (e && e.message) || String(e)
1862
+ return null
1863
+ })
1864
+ : null
1865
+ if (index) {
1866
+ applyIndex(index)
1867
+ // A boot shard that does not arrive must not refuse a returning
1868
+ // visitor's drive: `boot()` reports it as `driveError` and carries on,
1869
+ // and a restore that rejected instead would answer the page's load
1870
+ // with an error and leave it with nothing. Every read that needs the
1871
+ // missing piece still names it.
1872
+ await loadShard(bootShard).catch((e) => {
1873
+ lastBoot.driveError = (e && e.message) || String(e)
1874
+ })
1875
+ lastBoot.onSite = PENDING.size
1876
+ }
1877
+ return lastBoot
1878
+ }
1879
+ )
1532
1880
  if (resolveBoot) resolveBoot(ready)
1533
1881
  return ready
1534
1882
  },
@@ -1548,12 +1896,27 @@ const api = {
1548
1896
  bootInfo: () => lastBoot,
1549
1897
  stats: () => ({
1550
1898
  files: FILES.size,
1899
+ // the files whose bytes are still on the site, and the pieces they are in
1900
+ onSite: PENDING.size,
1901
+ shards: SHARDS.size,
1551
1902
  // the drive's total in bytes, measured per entry the way statSync does it -
1552
1903
  // the stored representation's own length would count the marker and the
1553
- // base64 expansion instead - and cached, so a second call scans nothing
1554
- bytes: [...FILES.keys()].reduce((total, abs) => total + entrySizeAt(abs), 0)
1904
+ // base64 expansion instead - and cached, so a second call scans nothing.
1905
+ // Both maps: on a sharded site the pieces nobody read are still part of the
1906
+ // drive, and their sizes are in the index.
1907
+ bytes: [...FILES.keys(), ...PENDING.keys()].reduce(
1908
+ (total, abs) => total + entrySizeAt(abs),
1909
+ 0
1910
+ )
1555
1911
  }),
1556
1912
  run: (payload) => runProgram(payload),
1913
+ // the pieces a drive ships in: fetched by the host for the paths a request
1914
+ // names, and by a run for the whole drive - a program may read anything
1915
+ ensurePaths,
1916
+ ensureAll,
1917
+ // the refusal a read of a not-yet-fetched file raises, so the host can retry
1918
+ // on it rather than answer 500
1919
+ PENDING_CODE,
1557
1920
  // the file a program names, so the host can tell a .sas program from a .js one
1558
1921
  // and refuse the former instead of compiling SAS source as JavaScript
1559
1922
  resolveProgram: (program) => resolveProgram(program),