@stacksjs/database 0.70.204 → 0.70.206
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/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/migration-dialect.js +3 -2
- package/dist/migrations.d.ts +60 -0
- package/dist/migrations.js +77 -6
- package/dist/model-sources.d.ts +27 -0
- package/dist/model-sources.js +76 -0
- package/package.json +11 -11
package/dist/index.d.ts
CHANGED
|
@@ -105,6 +105,9 @@ export * from './defaults';
|
|
|
105
105
|
// Dialect classification for the committed migration corpus, so a corpus
|
|
106
106
|
// emitted for one database fails loudly before a single statement runs.
|
|
107
107
|
export * from './migration-dialect';
|
|
108
|
+
// Model resolution for the generator: userland + framework defaults, flattened
|
|
109
|
+
// because bun-query-builder's loadModels reads only the top level of a dir.
|
|
110
|
+
export * from './model-sources';
|
|
108
111
|
// Database bootstrap: probe the target, and create it over a maintenance
|
|
109
112
|
// connection we open ourselves rather than through bun-query-builder, whose
|
|
110
113
|
// connection string is rebuilt from process.env and cannot be redirected.
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export { migrateRbacTables } from "./rbac-tables";
|
|
|
29
29
|
export * from "./sql-helpers";
|
|
30
30
|
export * from "./defaults";
|
|
31
31
|
export * from "./migration-dialect";
|
|
32
|
+
export * from "./model-sources";
|
|
32
33
|
export * from "./ensure-database";
|
|
33
34
|
export { auditForeignKeys, findFkOrphans, getDeclaredFKs, getLiveFKs } from "./fk-audit";
|
|
34
35
|
export { auditUniqueIndexes, getDeclaredUniques, getLiveUniqueIndexes } from "./unique-audit";
|
|
@@ -97,8 +97,9 @@ export function formatMigrationDialectError(audit, target, dir) {
|
|
|
97
97
|
"Nothing was migrated, so the database is unchanged.",
|
|
98
98
|
"",
|
|
99
99
|
"Stacks ships one set of migration files, and they are emitted for a single database.",
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
"Regenerate them from your models:",
|
|
101
|
+
` ./buddy migrate:regenerate ${target}`,
|
|
102
|
+
"or point DB_CONNECTION back at the database they were written for.",
|
|
102
103
|
"",
|
|
103
104
|
`If you know this corpus is correct, re-run with ${DIALECT_OVERRIDE_ENV}=1 to proceed anyway.`
|
|
104
105
|
].join(`
|
package/dist/migrations.d.ts
CHANGED
|
@@ -45,6 +45,27 @@ export declare function preprocessSqliteMigrations(): void;
|
|
|
45
45
|
export declare function ensureDatabaseReady(): Promise<void>;
|
|
46
46
|
/** Test seam: forget that the bootstrap already ran. */
|
|
47
47
|
export declare function resetDatabaseBootstrapCache(): void;
|
|
48
|
+
/**
|
|
49
|
+
* Count how many migrations have been recorded as applied in the
|
|
50
|
+
* `migrations` table. Returns 0 when the table doesn't exist yet
|
|
51
|
+
* (fresh database, first ever migration) — bun-query-builder
|
|
52
|
+
* creates the table during the first `executeMigration` call.
|
|
53
|
+
*
|
|
54
|
+
* Used by {@link runDatabaseMigration} before + after the migration
|
|
55
|
+
* run so the caller can report `applied = afterCount - beforeCount`
|
|
56
|
+
* — distinguishes the "nothing to migrate" path from a real apply
|
|
57
|
+
* in the CLI outro (user-reported messaging gap).
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* How many migrations the target database has already recorded.
|
|
61
|
+
*
|
|
62
|
+
* Exported because regeneration must refuse to renumber a corpus that a live
|
|
63
|
+
* database has bookkeeping against: the migrations table keys on the FILENAME,
|
|
64
|
+
* so renaming files makes an applied migration look pending. One of them,
|
|
65
|
+
* 0000000098-revoke-legacy-long-lived-tokens.sql, is a hard
|
|
66
|
+
* `DELETE FROM oauth_access_tokens`, so a careless renumber re-runs a data wipe.
|
|
67
|
+
*/
|
|
68
|
+
export declare function countAppliedMigrations(): Promise<number>;
|
|
48
69
|
export declare function runDatabaseMigration(): Promise<Result<string, Error>>;
|
|
49
70
|
/**
|
|
50
71
|
* Reset the database (drop all tables)
|
|
@@ -58,6 +79,38 @@ export declare function resetDatabase(): Promise<Result<string, Error>>;
|
|
|
58
79
|
*/
|
|
59
80
|
export declare function previewPendingMigrations(options?: GenerateMigrationsOptions): Promise<MigrationOperation[]>;
|
|
60
81
|
export declare function generateMigrations(options?: GenerateMigrationsOptions): Promise<Result<string, Error>>;
|
|
82
|
+
/**
|
|
83
|
+
* Write generated SQL to `database/migrations/` so the runner picks it up.
|
|
84
|
+
* Returns the number of files written.
|
|
85
|
+
*/
|
|
86
|
+
/**
|
|
87
|
+
* Enum types a generated corpus USES but never DEFINES.
|
|
88
|
+
*
|
|
89
|
+
* Postgres enums have to exist before a column can be typed with them, so a
|
|
90
|
+
* dangling reference is a guaranteed mid-migration failure rather than a
|
|
91
|
+
* cosmetic problem.
|
|
92
|
+
*/
|
|
93
|
+
export declare function findDanglingTypeReferences(statements: string[]): string[];
|
|
94
|
+
/**
|
|
95
|
+
* Rebuild the whole migration corpus for one dialect, from the models.
|
|
96
|
+
*
|
|
97
|
+
* This is the fix for a corpus that cannot run on the configured database.
|
|
98
|
+
* Translating the committed SQLite DDL was never viable: the emission threw
|
|
99
|
+
* away varchar lengths, numeric scale and every foreign key (40 of them are
|
|
100
|
+
* `SELECT 1;` stubs). All of that still exists in the model definitions, so
|
|
101
|
+
* regenerating recovers the intent instead of guessing at it.
|
|
102
|
+
*
|
|
103
|
+
* Unlike `persistGeneratedMigrations`, which appends a diff on top of what is
|
|
104
|
+
* already committed, this produces a COMPLETE corpus numbered from 1 and
|
|
105
|
+
* replaces what is there. Callers are responsible for confirming that with the
|
|
106
|
+
* user, and for refusing when the target database already has migrations
|
|
107
|
+
* recorded against the old filenames.
|
|
108
|
+
*/
|
|
109
|
+
export declare function regenerateMigrationCorpus(options?: {
|
|
110
|
+
dialect?: string
|
|
111
|
+
dir?: string
|
|
112
|
+
dryRun?: boolean
|
|
113
|
+
}): Promise<Result<RegeneratedCorpus, Error>>;
|
|
61
114
|
/**
|
|
62
115
|
* Group generated SQL by the migration filename style the runner already
|
|
63
116
|
* uses for hand-written files: `create-<table>-table`,
|
|
@@ -85,6 +138,13 @@ export declare interface GenerateMigrationsOptions {
|
|
|
85
138
|
applyRenames?: boolean
|
|
86
139
|
fromDb?: boolean
|
|
87
140
|
}
|
|
141
|
+
export declare interface RegeneratedCorpus {
|
|
142
|
+
dialect: string
|
|
143
|
+
models: number
|
|
144
|
+
files: Array<{ name: string, statements: number }>
|
|
145
|
+
removed: string[]
|
|
146
|
+
dir: string
|
|
147
|
+
}
|
|
88
148
|
declare interface GeneratedGroup {
|
|
89
149
|
label: string
|
|
90
150
|
statements: string[]
|
package/dist/migrations.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var {require}=import.meta;import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
1
|
+
var {require}=import.meta;import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { log as _log } from "@stacksjs/logging";
|
|
4
4
|
const log = {
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
probeTargetDatabase,
|
|
30
30
|
resolveConnectionTarget
|
|
31
31
|
} from "./ensure-database";
|
|
32
|
+
import { resolveModelSources } from "./model-sources";
|
|
32
33
|
import { frameworkManagedColumns, withoutManagedColumnDrops, withoutManagedColumnDropSql } from "./managed-columns";
|
|
33
34
|
import { acquireMigrationLock } from "./migration-lock";
|
|
34
35
|
import { env as envVars } from "@stacksjs/env";
|
|
@@ -303,7 +304,7 @@ async function restoreHiddenMigrations(hidden) {
|
|
|
303
304
|
await fs.rename(h, original);
|
|
304
305
|
} catch {}
|
|
305
306
|
}
|
|
306
|
-
async function countAppliedMigrations() {
|
|
307
|
+
export async function countAppliedMigrations() {
|
|
307
308
|
try {
|
|
308
309
|
const row = await db.selectFrom("migrations").select((eb) => eb.fn.count("id").as("n")).executeTakeFirst();
|
|
309
310
|
if (!row)
|
|
@@ -492,9 +493,11 @@ export async function previewPendingMigrations(options = {}) {
|
|
|
492
493
|
return [];
|
|
493
494
|
}
|
|
494
495
|
}
|
|
496
|
+
function snapshotDirLabel() {
|
|
497
|
+
return qbConfig?.snapshotDir || QB_SNAPSHOT_DIR;
|
|
498
|
+
}
|
|
495
499
|
function resolveSnapshotDir() {
|
|
496
|
-
|
|
497
|
-
return join(process.cwd(), configured || QB_SNAPSHOT_DIR);
|
|
500
|
+
return join(process.cwd(), snapshotDirLabel());
|
|
498
501
|
}
|
|
499
502
|
function detectSnapshotDialectMismatch(dialect) {
|
|
500
503
|
const qbDir = resolveSnapshotDir();
|
|
@@ -519,8 +522,10 @@ export async function generateMigrations(options = {}) {
|
|
|
519
522
|
log.debug("Generating migrations...");
|
|
520
523
|
configureQueryBuilder();
|
|
521
524
|
const dialect = getDialect(), mismatch = detectSnapshotDialectMismatch(dialect);
|
|
522
|
-
if (mismatch)
|
|
523
|
-
|
|
525
|
+
if (mismatch) {
|
|
526
|
+
const snapshotDir = snapshotDirLabel();
|
|
527
|
+
return err(Error(`Refusing to generate migrations: resolved dialect "${dialect}" has no snapshot in ${snapshotDir}/, but "${mismatch}" does. DB_CONNECTION is likely unset or wrong ` + "(missing .env?) \u2014 generating now would write a full duplicate migration set in the " + `wrong dialect. Set DB_CONNECTION=${mismatch} (or your intended dialect) and retry. To intentionally start a new dialect from scratch, remove ${snapshotDir}/model-snapshot.${mismatch}.json first.`));
|
|
528
|
+
}
|
|
524
529
|
const { modelsDir, skip } = prepareMigrationModelsDir();
|
|
525
530
|
if (skip) {
|
|
526
531
|
log.debug("No app/Models directory found; using committed framework migrations");
|
|
@@ -550,6 +555,72 @@ export async function generateMigrations(options = {}) {
|
|
|
550
555
|
return err(handleError("Migration generation failed", error));
|
|
551
556
|
}
|
|
552
557
|
}
|
|
558
|
+
export function findDanglingTypeReferences(statements) {
|
|
559
|
+
const defined = new Set, referenced = new Set;
|
|
560
|
+
for (const statement of statements) {
|
|
561
|
+
for (const match of statement.matchAll(/CREATE\s+TYPE\s+"([^"]+)"/gi))
|
|
562
|
+
defined.add(match[1]);
|
|
563
|
+
for (const match of statement.matchAll(/\bTYPE\s+"([^"]+)"/gi))
|
|
564
|
+
referenced.add(match[1]);
|
|
565
|
+
}
|
|
566
|
+
return [...referenced].filter((name) => !defined.has(name)).sort();
|
|
567
|
+
}
|
|
568
|
+
export async function regenerateMigrationCorpus(options = {}) {
|
|
569
|
+
try {
|
|
570
|
+
configureQueryBuilder();
|
|
571
|
+
const dialect = options.dialect ?? getQbDialect(), dir = options.dir ?? join(process.cwd(), "database", "migrations"), sources = resolveModelSources();
|
|
572
|
+
if (!sources)
|
|
573
|
+
return err(Error("No models found. Define models in app/Models, or ensure the framework defaults at storage/framework/defaults/app/Models are present."));
|
|
574
|
+
try {
|
|
575
|
+
writeFileSync(join(sources.dir, `.qb-migrations.${dialect}.json`), JSON.stringify({ plan: { dialect, tables: [] } }));
|
|
576
|
+
} catch {}
|
|
577
|
+
const snapshotPath = join(resolveSnapshotDir(), `model-snapshot.${dialect}.json`), parkedSnapshot = `${snapshotPath}.regenerating`;
|
|
578
|
+
let snapshotParked = !1;
|
|
579
|
+
if (existsSync(snapshotPath))
|
|
580
|
+
try {
|
|
581
|
+
renameSync(snapshotPath, parkedSnapshot);
|
|
582
|
+
snapshotParked = !0;
|
|
583
|
+
} catch {}
|
|
584
|
+
let result;
|
|
585
|
+
try {
|
|
586
|
+
result = await qbGenerateMigration(sources.dir, { dialect, dryRun: !0 });
|
|
587
|
+
} finally {
|
|
588
|
+
if (snapshotParked)
|
|
589
|
+
try {
|
|
590
|
+
renameSync(parkedSnapshot, snapshotPath);
|
|
591
|
+
} catch {}
|
|
592
|
+
}
|
|
593
|
+
const statements = result.sqlStatements ?? [];
|
|
594
|
+
if (statements.length === 0)
|
|
595
|
+
return err(Error(`The generator produced no SQL for dialect "${dialect}".`));
|
|
596
|
+
const dangling = findDanglingTypeReferences(statements);
|
|
597
|
+
if (dangling.length > 0)
|
|
598
|
+
return err(Error(`The generator emitted ${dangling.length} reference(s) to enum type(s) it never creates: ${dangling.slice(0, 5).join(", ")}${dangling.length > 5 ? `, +${dangling.length - 5} more` : ""}. Writing this corpus would fail partway through a migration. This is a bug in the migration generator, not in your models.`));
|
|
599
|
+
const groups = groupGeneratedStatements(statements);
|
|
600
|
+
let existing = [];
|
|
601
|
+
try {
|
|
602
|
+
existing = readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
|
|
603
|
+
} catch {}
|
|
604
|
+
const files = groups.map((group, index) => ({
|
|
605
|
+
name: `${String(index + 1).padStart(10, "0")}-${group.label}.sql`,
|
|
606
|
+
statements: group.statements.length
|
|
607
|
+
}));
|
|
608
|
+
if (options.dryRun)
|
|
609
|
+
return ok({ dialect, models: sources.models.length, files, removed: existing, dir });
|
|
610
|
+
mkdirSync(dir, { recursive: !0 });
|
|
611
|
+
for (const file of existing)
|
|
612
|
+
unlinkSync(join(dir, file));
|
|
613
|
+
groups.forEach((group, index) => {
|
|
614
|
+
const body = `${group.statements.map((s) => s.trim().replace(/;\s*$/, "")).join(`;
|
|
615
|
+
`)};
|
|
616
|
+
`;
|
|
617
|
+
writeFileSync(join(dir, files[index].name), body);
|
|
618
|
+
});
|
|
619
|
+
return ok({ dialect, models: sources.models.length, files, removed: existing, dir });
|
|
620
|
+
} catch (error) {
|
|
621
|
+
return err(handleError("Migration regeneration failed", error));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
553
624
|
function persistGeneratedMigrations(sqlStatements) {
|
|
554
625
|
if (!sqlStatements?.length)
|
|
555
626
|
return 0;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the flattened copy lives. Under the framework runtime directory rather
|
|
3
|
+
* than the OS temp dir so it is inspectable when a generation goes wrong, and
|
|
4
|
+
* so it lands on the same filesystem as the source.
|
|
5
|
+
*/
|
|
6
|
+
export declare function modelStagingDir(): string;
|
|
7
|
+
/**
|
|
8
|
+
* Resolve models from userland and framework defaults.
|
|
9
|
+
*
|
|
10
|
+
* Returns `null` when neither root holds a model, which callers should treat as
|
|
11
|
+
* "nothing to generate from" rather than as an error: it is the legitimate
|
|
12
|
+
* state of a project that has not defined any models yet.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveModelSources(options?: { userRoot?: string, frameworkRoot?: string }): ResolvedModelSources | null;
|
|
15
|
+
/** Remove the staging directory. Safe to call when it was never created. */
|
|
16
|
+
export declare function cleanupModelStaging(): void;
|
|
17
|
+
export declare interface ModelSource {
|
|
18
|
+
file: string
|
|
19
|
+
name: string
|
|
20
|
+
origin: 'user' | 'framework'
|
|
21
|
+
}
|
|
22
|
+
export declare interface ResolvedModelSources {
|
|
23
|
+
dir: string
|
|
24
|
+
models: ModelSource[]
|
|
25
|
+
roots: string[]
|
|
26
|
+
staged: boolean
|
|
27
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { path } from "@stacksjs/path";
|
|
4
|
+
function collectModels(root, origin) {
|
|
5
|
+
if (!existsSync(root))
|
|
6
|
+
return [];
|
|
7
|
+
const out = [], walk = (dir) => {
|
|
8
|
+
let entries;
|
|
9
|
+
try {
|
|
10
|
+
entries = readdirSync(dir, { withFileTypes: !0 });
|
|
11
|
+
} catch {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const full = join(dir, entry.name);
|
|
16
|
+
if (entry.isDirectory()) {
|
|
17
|
+
walk(full);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (!entry.name.endsWith(".ts"))
|
|
21
|
+
continue;
|
|
22
|
+
if (entry.name.startsWith(".") || entry.name.startsWith("index"))
|
|
23
|
+
continue;
|
|
24
|
+
out.push({ file: full, name: entry.name.replace(/\.ts$/, ""), origin });
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
walk(root);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
export function modelStagingDir() {
|
|
31
|
+
return path.frameworkRuntimePath("model-sources");
|
|
32
|
+
}
|
|
33
|
+
function stage(models) {
|
|
34
|
+
const dir = modelStagingDir();
|
|
35
|
+
try {
|
|
36
|
+
rmSync(dir, { recursive: !0, force: !0 });
|
|
37
|
+
} catch {}
|
|
38
|
+
mkdirSync(dir, { recursive: !0 });
|
|
39
|
+
for (const model of models) {
|
|
40
|
+
const target = join(dir, `${model.name}.ts`);
|
|
41
|
+
try {
|
|
42
|
+
symlinkSync(model.file, target);
|
|
43
|
+
} catch {
|
|
44
|
+
try {
|
|
45
|
+
writeFileSync(target, readFileSync(model.file));
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return dir;
|
|
50
|
+
}
|
|
51
|
+
export function resolveModelSources(options = {}) {
|
|
52
|
+
const userRoot = options.userRoot ?? path.userModelsPath(), frameworkRoot = options.frameworkRoot ?? path.frameworkPath("defaults/app/Models"), user = collectModels(userRoot, "user"), framework = collectModels(frameworkRoot, "framework");
|
|
53
|
+
if (user.length === 0 && framework.length === 0)
|
|
54
|
+
return null;
|
|
55
|
+
const byName = new Map;
|
|
56
|
+
for (const model of framework)
|
|
57
|
+
byName.set(model.name, model);
|
|
58
|
+
for (const model of user)
|
|
59
|
+
byName.set(model.name, model);
|
|
60
|
+
const models = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)), roots = [];
|
|
61
|
+
if (user.length > 0)
|
|
62
|
+
roots.push(userRoot);
|
|
63
|
+
if (framework.length > 0)
|
|
64
|
+
roots.push(frameworkRoot);
|
|
65
|
+
const onlyUser = framework.length === 0, allFlat = models.every((m) => basename(join(m.file, "..")) === basename(onlyUser ? userRoot : frameworkRoot));
|
|
66
|
+
if (roots.length === 1 && allFlat)
|
|
67
|
+
return { dir: roots[0], models, roots, staged: !1 };
|
|
68
|
+
return { dir: stage(models), models, roots, staged: !0 };
|
|
69
|
+
}
|
|
70
|
+
export function cleanupModelStaging() {
|
|
71
|
+
const dir = modelStagingDir();
|
|
72
|
+
try {
|
|
73
|
+
if (existsSync(dir) && lstatSync(dir).isDirectory())
|
|
74
|
+
rmSync(dir, { recursive: !0, force: !0 });
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/database",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.70.
|
|
5
|
+
"version": "0.70.206",
|
|
6
6
|
"description": "The Stacks database integration.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -56,19 +56,19 @@
|
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@stacksjs/ts-validation": "^0.5.0",
|
|
59
|
-
"bun-query-builder": "^0.1.
|
|
59
|
+
"bun-query-builder": "^0.1.63",
|
|
60
60
|
"dynamodb-tooling": "^0.3.2"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@stacksjs/cli": "0.70.
|
|
64
|
-
"@stacksjs/config": "0.70.
|
|
65
|
-
"@stacksjs/logging": "0.70.
|
|
66
|
-
"@stacksjs/router": "0.70.
|
|
63
|
+
"@stacksjs/cli": "0.70.206",
|
|
64
|
+
"@stacksjs/config": "0.70.206",
|
|
65
|
+
"@stacksjs/logging": "0.70.206",
|
|
66
|
+
"@stacksjs/router": "0.70.206",
|
|
67
67
|
"better-dx": "^0.2.17",
|
|
68
|
-
"@stacksjs/path": "0.70.
|
|
69
|
-
"@stacksjs/query-builder": "0.70.
|
|
70
|
-
"@stacksjs/storage": "0.70.
|
|
71
|
-
"@stacksjs/strings": "0.70.
|
|
72
|
-
"@stacksjs/utils": "0.70.
|
|
68
|
+
"@stacksjs/path": "0.70.206",
|
|
69
|
+
"@stacksjs/query-builder": "0.70.206",
|
|
70
|
+
"@stacksjs/storage": "0.70.206",
|
|
71
|
+
"@stacksjs/strings": "0.70.206",
|
|
72
|
+
"@stacksjs/utils": "0.70.206"
|
|
73
73
|
}
|
|
74
74
|
}
|