acrylblends 0.7.5 → 0.7.7

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.
Files changed (48) hide show
  1. package/dist/index.js +277 -5
  2. package/dist/index.js.map +1 -1
  3. package/docs/framework/UPSTREAM.md +1 -1
  4. package/docs/framework/guides/data.md +101 -2
  5. package/docs/framework/upstream.json +2 -2
  6. package/lib/database-D_UN6We8.d.ts +44 -0
  7. package/lib/db/sqlite.d.ts +1 -1
  8. package/lib/db.d.ts +84 -1
  9. package/lib/kv/db.d.ts +12 -0
  10. package/lib/kv/db.js +57 -0
  11. package/lib/kv/json.d.ts +11 -0
  12. package/lib/kv/json.js +161 -0
  13. package/lib/kv-DWDm0Lja.d.ts +39 -0
  14. package/lib/kv.d.ts +93 -0
  15. package/lib/kv.js +2 -0
  16. package/lib/src-DRFVEQQb.js +90 -0
  17. package/package.json +13 -1
  18. package/plugins/acryl-db/upstream.json +1 -1
  19. package/plugins/acryl-db-sqlite/upstream.json +1 -1
  20. package/plugins/acryl-inspect/upstream.json +1 -1
  21. package/plugins/acryl-kv/LICENSE +47 -0
  22. package/plugins/acryl-kv/plugin.json +12 -0
  23. package/plugins/acryl-kv/src/check.ts +58 -0
  24. package/plugins/acryl-kv/src/copy.ts +39 -0
  25. package/plugins/acryl-kv/src/cordis.ts +13 -0
  26. package/plugins/acryl-kv/src/index.ts +14 -0
  27. package/plugins/acryl-kv/src/kv.ts +37 -0
  28. package/plugins/acryl-kv/upstream.json +18 -0
  29. package/plugins/acryl-kv-db/LICENSE +47 -0
  30. package/plugins/acryl-kv-db/plugin.json +13 -0
  31. package/plugins/acryl-kv-db/src/index.ts +37 -0
  32. package/plugins/acryl-kv-db/src/open.ts +80 -0
  33. package/plugins/acryl-kv-db/upstream.json +21 -0
  34. package/plugins/acryl-kv-json/LICENSE +47 -0
  35. package/plugins/acryl-kv-json/plugin.json +13 -0
  36. package/plugins/acryl-kv-json/src/index.ts +71 -0
  37. package/plugins/acryl-kv-json/src/open.ts +185 -0
  38. package/plugins/acryl-kv-json/upstream.json +22 -0
  39. package/plugins/acryl-node-server/src/index.ts +12 -1
  40. package/plugins/acryl-node-server/upstream.json +2 -2
  41. package/plugins/acryl-router/upstream.json +1 -1
  42. package/plugins/acryl-router-http/upstream.json +1 -1
  43. package/plugins/acryl-state/upstream.json +1 -1
  44. package/plugins/desktop/upstream.json +1 -1
  45. package/plugins/desktop-web/upstream.json +1 -1
  46. package/plugins/tsconfig.json +2 -0
  47. package/templates/todo/upstream.json +1 -1
  48. package/lib/index-D4FAsBZu.d.ts +0 -85
package/dist/index.js CHANGED
@@ -189,6 +189,19 @@ function mergeDependencies(appDir, dependencies) {
189
189
  writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
190
190
  return added;
191
191
  }
192
+ /** Declare a file as an opener in package.json (`"acryl": { "opener": [...] }`): the one place that may import a database provider. */
193
+ function mergeOpener(appDir, opener) {
194
+ const file = join(appDir, "package.json");
195
+ const manifest = JSON.parse(readFileSync(file, "utf8"));
196
+ const current = manifest.acryl?.opener;
197
+ const list = typeof current === "string" ? [current] : current ?? [];
198
+ if (list.includes(opener)) return;
199
+ manifest.acryl = {
200
+ ...manifest.acryl,
201
+ opener: [...list, opener]
202
+ };
203
+ writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
204
+ }
192
205
  /**
193
206
  * Add a plugin's development dependencies and scripts to an app's package.json: nothing the app already has is replaced. Returns the names of the development dependencies
194
207
  * and scripts added.
@@ -315,7 +328,7 @@ var UsageError = class extends Error {
315
328
  function parseInitOptions(argv) {
316
329
  let parsed;
317
330
  try {
318
- parsed = parse$1(argv);
331
+ parsed = parse$2(argv);
319
332
  } catch (error) {
320
333
  throw new UsageError(error.message);
321
334
  }
@@ -334,7 +347,7 @@ function parseInitOptions(argv) {
334
347
  yes: values.yes === true
335
348
  };
336
349
  }
337
- const parse$1 = (argv) => parseArgs({
350
+ const parse$2 = (argv) => parseArgs({
338
351
  args: [...argv],
339
352
  allowPositionals: true,
340
353
  strict: true,
@@ -405,7 +418,7 @@ async function init(options, io) {
405
418
  function parseAddOptions(argv) {
406
419
  let parsed;
407
420
  try {
408
- parsed = parse(argv);
421
+ parsed = parse$1(argv);
409
422
  } catch (error) {
410
423
  throw new UsageError(error.message);
411
424
  }
@@ -415,7 +428,7 @@ function parseAddOptions(argv) {
415
428
  directory: parsed.values.dir
416
429
  };
417
430
  }
418
- const parse = (argv) => parseArgs({
431
+ const parse$1 = (argv) => parseArgs({
419
432
  args: [...argv],
420
433
  allowPositionals: true,
421
434
  strict: true,
@@ -442,7 +455,11 @@ async function add(options, io) {
442
455
  io.stderr.write("error: \"db\" is the Cordis plugin for a Blend app. A plain Acryl app opens its database with openDb from \"acrylblends/db/sqlite\" (see docs/framework/guides/data.md)\n");
443
456
  return 1;
444
457
  }
445
- const names = options.names.filter((name) => !external.includes(name)).map((name) => name === "db" ? "acryl-db-sqlite" : name).map((name) => name === "desktop" && plain ? "desktop-web" : name);
458
+ if (plain && options.names.includes("kv")) {
459
+ io.stderr.write("error: \"kv\" is the Cordis plugin for a Blend app. A plain Acryl app opens its store with openKv from \"acrylblends/kv/json\" (see docs/framework/guides/data.md#a-tiny-app-kv)\n");
460
+ return 1;
461
+ }
462
+ const names = options.names.filter((name) => !external.includes(name)).map((name) => name === "db" ? "acryl-db-sqlite" : name).map((name) => name === "kv" ? "acryl-kv-json" : name).map((name) => name === "desktop" && plain ? "desktop-web" : name);
446
463
  if (names.length === 0) return 0;
447
464
  const already = names.filter((name) => present(appDir, name));
448
465
  if (already.length > 0) {
@@ -456,6 +473,7 @@ async function add(options, io) {
456
473
  addedDependencies.push(...mergeDependencies(appDir, plugin.dependencies));
457
474
  const tooling = mergeTooling(appDir, plugin);
458
475
  appendGitignore(appDir, plugin.gitignore);
476
+ if (plugin.name === "acryl-db-sqlite") mergeOpener(appDir, "src/app.ts");
459
477
  addedDependencies.push(...tooling.devDependencies);
460
478
  if (tooling.scripts.length > 0) io.stdout.write(`new scripts: ${tooling.scripts.map((name) => `npm run ${name}`).join(", ")}\n`);
461
479
  }
@@ -551,6 +569,170 @@ async function buildClient(options, io) {
551
569
  return 0;
552
570
  }
553
571
  //#endregion
572
+ //#region src/check-deps.ts
573
+ /**
574
+ * The dependency rule (spec 049): app code imports a port, never a provider. A port is `acrylblends/db` (the `Db` type); a provider is a
575
+ * driver or the one function that opens a database. Only the opener, a file the app names once in package.json, may import a provider.
576
+ */
577
+ /** A package that is a database driver. An import of it, or of anything below it, is a provider import. */
578
+ const DRIVERS = [
579
+ "node:sqlite",
580
+ "sqlite",
581
+ "sqlite3",
582
+ "better-sqlite3",
583
+ "@libsql/client",
584
+ "libsql",
585
+ "pg",
586
+ "pg-pool",
587
+ "pg-native",
588
+ "postgres",
589
+ "@electric-sql/pglite",
590
+ "@neondatabase/serverless",
591
+ "@vercel/postgres",
592
+ "mysql",
593
+ "mysql2"
594
+ ];
595
+ /** The openers a provider ships: importing one of them is opening a database. */
596
+ const OPENERS = ["acrylblends/db/sqlite", "acryl-db-sqlite"];
597
+ /** The desktop shell is where Electron lives (`acrylblends add desktop` copies it). */
598
+ const ELECTRON = "electron";
599
+ const ELECTRON_HOMES = ["src/plugins/desktop/", "src/plugins/desktop-web/"];
600
+ /** The provider plugins themselves may import what they wrap. */
601
+ const PROVIDER_HOMES = ["src/plugins/acryl-db-sqlite/"];
602
+ const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/u;
603
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
604
+ "node_modules",
605
+ "dist",
606
+ "lib",
607
+ "build",
608
+ "coverage"
609
+ ]);
610
+ const ROOTS = [
611
+ "src",
612
+ "test",
613
+ "tests",
614
+ "extensions"
615
+ ];
616
+ const matches = (specifier, name) => specifier === name || specifier.startsWith(`${name}/`);
617
+ /** What a specifier is to the rule, or undefined when it is not a provider. */
618
+ function classifySpecifier(specifier) {
619
+ if (DRIVERS.some((name) => matches(specifier, name))) return "driver";
620
+ if (OPENERS.some((name) => matches(specifier, name))) return "opener";
621
+ if (matches(specifier, ELECTRON)) return "electron";
622
+ }
623
+ /** The source with every comment replaced by spaces (newlines kept), so a line number still means what it did and a commented import is not an import. */
624
+ function blankComments(source) {
625
+ let out = "";
626
+ let i = 0;
627
+ while (i < source.length) {
628
+ const c = source[i];
629
+ const next = source[i + 1];
630
+ if (c === "/" && next === "/") {
631
+ const end = source.indexOf("\n", i);
632
+ const stop = end === -1 ? source.length : end;
633
+ out += " ".repeat(stop - i);
634
+ i = stop;
635
+ } else if (c === "/" && next === "*") {
636
+ const end = source.indexOf("*/", i + 2);
637
+ const stop = end === -1 ? source.length : end + 2;
638
+ out += source.slice(i, stop).replace(/[^\n]/gu, " ");
639
+ i = stop;
640
+ } else if (c === "'" || c === "\"" || c === "`") {
641
+ let j = i + 1;
642
+ while (j < source.length && source[j] !== c) {
643
+ if (source[j] === "\\") j += 1;
644
+ else if (c !== "`" && source[j] === "\n") break;
645
+ j += 1;
646
+ }
647
+ out += source.slice(i, Math.min(j + 1, source.length));
648
+ i = j + 1;
649
+ } else {
650
+ out += c;
651
+ i += 1;
652
+ }
653
+ }
654
+ return out;
655
+ }
656
+ /** `import x from 'a'`, `export * from 'a'`, `import 'a'`, `import('a')` and `require('a')`, with the line of each. */
657
+ const IMPORT = /(?:^|[\s;{}()])(?:import\s*(?:type\s+)?(?:[\w*${},\s]*?\s*from\s*)?|export\s*(?:type\s+)?[\w*${},\s]*?\s*from\s*|import\s*\(\s*|require\s*\(\s*)(['"])([^'"\n]+)\1/gu;
658
+ function importsOf(source) {
659
+ const code = blankComments(source);
660
+ const found = [];
661
+ for (const match of code.matchAll(IMPORT)) {
662
+ const specifier = match[2];
663
+ const at = (match.index ?? 0) + match[0].length - specifier.length - 2;
664
+ found.push({
665
+ specifier,
666
+ line: code.slice(0, at).split("\n").length
667
+ });
668
+ }
669
+ return found;
670
+ }
671
+ function listSources(dir, out = []) {
672
+ if (!existsSync(dir)) return out;
673
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
674
+ if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
675
+ const path = join(dir, entry.name);
676
+ if (entry.isDirectory()) listSources(path, out);
677
+ else if (SOURCE.test(entry.name) && !entry.name.endsWith(".d.ts")) out.push(path);
678
+ }
679
+ return out;
680
+ }
681
+ /** The opener files the app declares: `"acryl": { "opener": "src/db.ts" }` (or a list) in package.json. */
682
+ function openersOf(appDir) {
683
+ const file = join(appDir, "package.json");
684
+ if (!existsSync(file)) return [];
685
+ const declared = JSON.parse(readFileSync(file, "utf8")).acryl?.opener;
686
+ return (typeof declared === "string" ? [declared] : Array.isArray(declared) ? declared : []).filter((entry) => typeof entry === "string");
687
+ }
688
+ /** Every provider import outside the declared opener (and the homes that may hold one), in file and line order. */
689
+ function findViolations(appDir) {
690
+ const openers = new Set(openersOf(appDir).map((entry) => entry.split(sep).join("/")));
691
+ const violations = [];
692
+ for (const root of ROOTS) for (const path of listSources(join(appDir, root)).sort()) {
693
+ const file = relative(appDir, path).split(sep).join("/");
694
+ if (openers.has(file)) continue;
695
+ for (const { specifier, line } of importsOf(readFileSync(path, "utf8"))) {
696
+ const kind = classifySpecifier(specifier);
697
+ if (kind === void 0) continue;
698
+ if (kind === "electron" && ELECTRON_HOMES.some((home) => file.startsWith(home))) continue;
699
+ if (kind !== "electron" && PROVIDER_HOMES.some((home) => file.startsWith(home))) continue;
700
+ violations.push({
701
+ file,
702
+ line,
703
+ specifier,
704
+ kind
705
+ });
706
+ }
707
+ }
708
+ return violations;
709
+ }
710
+ const GUIDE = "docs/framework/guides/data.md#why-the-database-is-opened-in-one-place";
711
+ function advice(violation, openers) {
712
+ if (violation.kind === "electron") return `"${violation.specifier}" is the desktop shell's: only src/plugins/desktop (added with "acrylblends add desktop") imports electron`;
713
+ const where = openers.length > 0 ? openers.join(", ") : "a file named in package.json \"acryl\": { \"opener\": \"...\" }";
714
+ return violation.kind === "driver" ? `"${violation.specifier}" is a database driver; take a Db (import type { Db } from "acrylblends/db") as an argument and let ${where} open the database` : `"${violation.specifier}" opens a database, and only ${where} may; take a Db (import type { Db } from "acrylblends/db") as an argument`;
715
+ }
716
+ /** Parse `check-deps [dir]`. */
717
+ function parseCheckDepsOptions(argv) {
718
+ const args = argv.filter((a) => !a.startsWith("-"));
719
+ if (args.length !== argv.length || args.length > 1) throw new Error("check-deps takes at most one argument: the app folder");
720
+ return { directory: args[0] };
721
+ }
722
+ /** Report each provider import outside the opener, as `file:line: why`. Returns the exit code: 1 when there is one. */
723
+ function checkDeps(options, io) {
724
+ const appDir = resolve(io.cwd, options.directory ?? ".");
725
+ const violations = findViolations(appDir);
726
+ if (violations.length === 0) {
727
+ io.stdout.write("check-deps: no app code imports a database provider outside the opener\n");
728
+ return 0;
729
+ }
730
+ const openers = openersOf(appDir);
731
+ for (const violation of violations) io.stderr.write(`${violation.file}:${violation.line}: ${advice(violation, openers)}\n`);
732
+ io.stderr.write(`See ${GUIDE}\n`);
733
+ return 1;
734
+ }
735
+ //#endregion
554
736
  //#region src/spawn.ts
555
737
  /**
556
738
  * How to start a package manager without the shell option (Node warns about passing args to a shell, DEP0190, and a `.cmd` shim cannot be spawned directly).
@@ -613,6 +795,81 @@ function processIo() {
613
795
  };
614
796
  }
615
797
  //#endregion
798
+ //#region src/kv-import.ts
799
+ /** Parse `kv import <kv.json> <database.sqlite> [--overwrite] [--table name] [--migrations dir]`. */
800
+ function parseKvOptions(argv) {
801
+ let parsed;
802
+ try {
803
+ parsed = parse(argv);
804
+ } catch (error) {
805
+ throw new UsageError(error.message);
806
+ }
807
+ const [action, from, to, ...rest] = parsed.positionals;
808
+ if (action !== "import") throw new UsageError("kv takes one subcommand: kv import <kv.json> <database.sqlite>");
809
+ if (from === void 0 || to === void 0 || rest.length > 0) throw new UsageError("kv import takes two arguments: the JSON file and the SQLite file");
810
+ return {
811
+ from,
812
+ to,
813
+ overwrite: parsed.values.overwrite ?? false,
814
+ table: parsed.values.table,
815
+ migrations: parsed.values.migrations
816
+ };
817
+ }
818
+ const parse = (argv) => parseArgs({
819
+ args: [...argv],
820
+ allowPositionals: true,
821
+ strict: true,
822
+ options: {
823
+ overwrite: { type: "boolean" },
824
+ table: { type: "string" },
825
+ migrations: { type: "string" }
826
+ }
827
+ });
828
+ /** The framework as the app has it installed (so the code that reads the file is the code the app runs), else this package's own. */
829
+ async function framework(io, subpath, own) {
830
+ const here = createRequire(resolve(io.cwd, "package.json"));
831
+ try {
832
+ return await import(here.resolve(`acrylblends/${subpath}`));
833
+ } catch {
834
+ return await import(new URL(`../lib/${own}`, import.meta.url).href);
835
+ }
836
+ }
837
+ /**
838
+ * Import a JSON-file kv store into the `kv` table of an app's SQLite database: the move from `acryl-kv-json` to SQL. The file is not changed.
839
+ * The database is opened with the app's `migrations/` folder when it has one (a database whose schema is newer than the files given is refused),
840
+ * and the table is created when missing. Refuses to overwrite a key the table already has unless `--overwrite`.
841
+ */
842
+ async function kvImport(options, io) {
843
+ const from = resolve(io.cwd, options.from);
844
+ const to = resolve(io.cwd, options.to);
845
+ if (!existsSync(from)) {
846
+ io.stderr.write(`error: ${from} does not exist\n`);
847
+ return 1;
848
+ }
849
+ const kvCore = await framework(io, "kv", "kv.js");
850
+ const json = await framework(io, "kv/json", "kv/json.js");
851
+ const onDb = await framework(io, "kv/db", "kv/db.js");
852
+ const sqlite = await framework(io, "db/sqlite", "db/sqlite.js");
853
+ const migrations = options.migrations !== void 0 ? resolve(io.cwd, options.migrations) : existsSync(resolve(io.cwd, "migrations")) ? resolve(io.cwd, "migrations") : void 0;
854
+ const source = await json.openKv({ path: from });
855
+ try {
856
+ const database = await sqlite.openDb({
857
+ path: to,
858
+ ...migrations === void 0 ? {} : { migrations }
859
+ });
860
+ try {
861
+ const target = onDb.kvOnDb(database.db, options.table === void 0 ? {} : { table: options.table });
862
+ const result = await kvCore.copyKv(source.kv, target, { overwrite: options.overwrite });
863
+ io.stdout.write(`imported ${result.copied} ${result.copied === 1 ? "key" : "keys"} from ${options.from} into ${options.to}${options.table === void 0 ? "" : ` (table ${options.table})`}${result.overwritten > 0 ? `, ${result.overwritten} overwritten` : ""}\n`);
864
+ return 0;
865
+ } finally {
866
+ await database.close();
867
+ }
868
+ } finally {
869
+ await source.close();
870
+ }
871
+ }
872
+ //#endregion
616
873
  //#region src/cli.ts
617
874
  /** The version of this package. */
618
875
  const VERSION = createRequire(import.meta.url)("../package.json").version;
@@ -624,6 +881,9 @@ Usage:
624
881
  a folder path brings an external plugin into its extensions/
625
882
  acrylblends plugins List the plugin library
626
883
  acrylblends build-client [root] [out] Compile the client modules once for production (src/client to dist/client)
884
+ acrylblends kv import <kv.json> <db> Move a JSON-file kv store into the kv table of a SQLite database [--overwrite] [--table t] [--migrations dir]
885
+ acrylblends check-deps [dir] Fail when app code imports a database driver or opener outside the file named in
886
+ package.json "acryl": { "opener": "src/db.ts" } (a port is imported, a provider is not)
627
887
 
628
888
  Options for init:
629
889
  --template <name> blank or agent (asked when not given); --agent means --template agent,
@@ -653,6 +913,8 @@ async function runCli(argv, io = processIo()) {
653
913
  if (command === "init") return await init(parseInitOptions(args), io);
654
914
  if (command === "add") return await add(parseAddOptions(args), io);
655
915
  if (command === "build-client") return await buildClient(parseBuildClientOptions(args), io);
916
+ if (command === "kv") return await kvImport(parseKv(args), io);
917
+ if (command === "check-deps") return checkDeps(parseCheckDeps(args), io);
656
918
  if (command === "plugins") return listPlugins(io);
657
919
  throw new UsageError(`unknown command "${command}"`);
658
920
  } catch (error) {
@@ -664,6 +926,16 @@ async function runCli(argv, io = processIo()) {
664
926
  return 1;
665
927
  }
666
928
  }
929
+ function parseCheckDeps(args) {
930
+ try {
931
+ return parseCheckDepsOptions(args);
932
+ } catch (error) {
933
+ throw new UsageError(error.message);
934
+ }
935
+ }
936
+ function parseKv(args) {
937
+ return parseKvOptions(args);
938
+ }
667
939
  //#endregion
668
940
  export { HELP, TEMPLATES, TargetNotEmptyError, UsageError, VERSION, add, assertTargetFree, closure, copyPlugin, copyTemplate, init, isOmitted, isTemplate, isValidName, libraryRoot, listPlugins, mergeDependencies, nameFrom, parseAddOptions, parseInitOptions, readLibrary, readTemplatePlugins, runCli, templateDir, templatesRoot };
669
941