diff --git a/package-lock.json b/package-lock.json
index 1adc9ef..d2ec25e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,15 +1,17 @@
{
- "name": "dashdbschema",
- "version": "1.0.0",
+ "name": "libdashstore",
+ "version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "dashdbschema",
- "version": "1.0.0",
+ "name": "libdashstore",
+ "version": "0.1.0",
"license": "ISC",
"dependencies": {
+ "@clack/prompts": "^1.7.0",
"@hpcc-js/wasm-graphviz": "^1.28.0",
+ "chalk": "^6.0.0",
"dotenv": "^17.4.2",
"mssql": "^12.7.0"
}
@@ -256,6 +258,34 @@
"node": ">=20"
}
},
+ "node_modules/@clack/core": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz",
+ "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-wrap-ansi": "^0.2.0",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 20.12.0"
+ }
+ },
+ "node_modules/@clack/prompts": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz",
+ "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==",
+ "license": "MIT",
+ "dependencies": {
+ "@clack/core": "1.4.3",
+ "fast-string-width": "^3.0.2",
+ "fast-wrap-ansi": "^0.2.0",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 20.12.0"
+ }
+ },
"node_modules/@hpcc-js/wasm-graphviz": {
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/@hpcc-js/wasm-graphviz/-/wasm-graphviz-1.28.0.tgz",
@@ -404,6 +434,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/chalk": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz",
+ "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
@@ -509,6 +551,30 @@
"node": ">=0.8.x"
}
},
+ "node_modules/fast-string-truncated-width": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
+ "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
+ "license": "MIT"
+ },
+ "node_modules/fast-string-width": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz",
+ "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-truncated-width": "^3.0.2"
+ }
+ },
+ "node_modules/fast-wrap-ansi": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz",
+ "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-string-width": "^3.0.2"
+ }
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -840,6 +906,12 @@
"node": ">=10"
}
},
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "license": "MIT"
+ },
"node_modules/sprintf-js": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
diff --git a/package.json b/package.json
index 9fbbf6c..274fc37 100644
--- a/package.json
+++ b/package.json
@@ -11,10 +11,13 @@
},
"scripts": {
"schema-graph": "node generate-schema-graph.js",
- "init-schema": "node scripts/init-schema.js"
+ "init-schema": "node scripts/init-schema.js",
+ "test:api": "node scripts/test-api.js"
},
"dependencies": {
+ "@clack/prompts": "^1.7.0",
"@hpcc-js/wasm-graphviz": "^1.28.0",
+ "chalk": "^6.0.0",
"dotenv": "^17.4.2",
"mssql": "^12.7.0"
}
diff --git a/schema-graph.dot b/schema-graph.dot
index 34f843e..fc6dc69 100644
--- a/schema-graph.dot
+++ b/schema-graph.dot
@@ -14,7 +14,7 @@ digraph schema {
company [label=<
|
|
| default_language_id? | INT FK |
|
|
|
>];
numbers [label=<>];
entity [label=<>];
- companyuser [label=<>];
+ companyuser [label=<>];
google_auth [label=<>];
}
subgraph cluster_i18n {
diff --git a/schema-graph.svg b/schema-graph.svg
index 8231a3b..7ee94ce 100644
--- a/schema-graph.svg
+++ b/schema-graph.svg
@@ -261,26 +261,31 @@
companyuser
-
-
-companyuser
-
-
-company_id
-INT
-FK
-
-
-google_auth_id
-INT
-FK
+
+
+companyuser
+
+
+id
+INT
+PK
+
+
+company_id
+INT
+FK
+
+
+google_auth_id
+INT
+FK
companyuser:sw->company:n
-
-
+
+
@@ -309,8 +314,8 @@
companyuser:se->google_auth:n
-
-
+
+
diff --git a/scripts/init-schema.js b/scripts/init-schema.js
new file mode 100644
index 0000000..6ff1d36
--- /dev/null
+++ b/scripts/init-schema.js
@@ -0,0 +1,25 @@
+import 'dotenv/config';
+import sql from 'mssql';
+import { SCHEMA_SCRIPTS } from '../src/schema.js';
+
+const config = {
+ server: process.env.MSSQL_SERVER,
+ database: process.env.MSSQL_DATABASE,
+ user: process.env.MSSQL_USER,
+ password: process.env.MSSQL_PASSWORD,
+ options: {
+ encrypt: false,
+ trustServerCertificate: true,
+ },
+};
+
+const pool = await sql.connect(config);
+
+try {
+ for (const script of SCHEMA_SCRIPTS) {
+ await pool.request().query(script);
+ }
+ console.log(`Schema init OK (${SCHEMA_SCRIPTS.length} scripts)`);
+} finally {
+ await pool.close();
+}
diff --git a/scripts/test-api.js b/scripts/test-api.js
new file mode 100644
index 0000000..64b4a6d
--- /dev/null
+++ b/scripts/test-api.js
@@ -0,0 +1,193 @@
+import 'dotenv/config';
+import sql from 'mssql';
+import chalk from 'chalk';
+import * as p from '@clack/prompts';
+import LibDashStore from '../src/index.js';
+
+const config = {
+ server: process.env.MSSQL_SERVER,
+ database: process.env.MSSQL_DATABASE,
+ user: process.env.MSSQL_USER,
+ password: process.env.MSSQL_PASSWORD,
+ options: {
+ encrypt: false,
+ trustServerCertificate: true,
+ },
+};
+
+function printResult(label, value) {
+ p.log.step(chalk.cyan(label));
+ const text =
+ value === undefined
+ ? chalk.dim('undefined')
+ : typeof value === 'string'
+ ? value
+ : JSON.stringify(value, null, 2);
+ console.log(chalk.white(text));
+ console.log();
+}
+
+async function shutdown(pool, code = 0) {
+ try {
+ await pool.close();
+ } catch {
+ // ignore
+ }
+ process.exit(code);
+}
+
+async function main() {
+ console.clear();
+ p.intro(chalk.bgMagenta.black(' libdashstore ') + chalk.dim(' api tester'));
+
+ const spin = p.spinner();
+ spin.start('Connecting to MSSQL…');
+
+ let pool;
+ try {
+ pool = await sql.connect(config);
+ } catch (err) {
+ spin.stop(chalk.red('Connection failed'));
+ p.log.error(err.message);
+ p.outro(chalk.red('Aborted'));
+ process.exit(1);
+ }
+
+ spin.message('Initializing schema…');
+ let client;
+ try {
+ client = await new LibDashStore(pool);
+ spin.stop(chalk.green('Ready'));
+ } catch (err) {
+ spin.stop(chalk.red('Initialize failed'));
+ p.log.error(err.message);
+ await shutdown(pool, 1);
+ }
+
+ p.note(
+ [
+ chalk.dim(`${config.server} / ${config.database}`),
+ chalk.dim('purge ends the session'),
+ ].join('\n'),
+ 'Connected',
+ );
+
+ while (true) {
+ const action = await p.select({
+ message: 'API method',
+ options: [
+ {
+ value: 'companyuser.getByEmail',
+ label: 'companyuser.getByEmail',
+ hint: 'email → id',
+ },
+ {
+ value: 'company.getByCompanyUserId',
+ label: 'company.getByCompanyUserId',
+ hint: 'id → [id, lang, root, name]',
+ },
+ {
+ value: 'category.getTree',
+ label: 'category.getTree',
+ hint: 'rootId → tree',
+ },
+ {
+ value: 'initialize',
+ label: 'initialize',
+ hint: 'purge + recreate schema',
+ },
+ {
+ value: 'purge',
+ label: chalk.red('purge'),
+ hint: 'drop all · exit',
+ },
+ { value: 'exit', label: 'exit', hint: 'quit without purge' },
+ ],
+ });
+
+ if (p.isCancel(action) || action === 'exit') {
+ p.outro(chalk.dim('Bye'));
+ await shutdown(pool, 0);
+ }
+
+ try {
+ if (action === 'companyuser.getByEmail') {
+ const email = await p.text({
+ message: 'Email',
+ placeholder: 'user@example.com',
+ validate: (v) => (!v?.trim() ? 'Required' : undefined),
+ });
+ if (p.isCancel(email)) continue;
+
+ const run = p.spinner();
+ run.start('Running…');
+ const id = await client.companyuser.getByEmail(email.trim());
+ run.stop(chalk.green('companyuser.getByEmail'));
+ printResult('id', id);
+ continue;
+ }
+
+ if (action === 'company.getByCompanyUserId') {
+ const raw = await p.text({
+ message: 'Company user id',
+ placeholder: '1',
+ validate: (v) =>
+ !/^\d+$/.test(v?.trim() ?? '') ? 'Integer required' : undefined,
+ });
+ if (p.isCancel(raw)) continue;
+
+ const run = p.spinner();
+ run.start('Running…');
+ const row = await client.company.getByCompanyUserId(Number(raw.trim()));
+ run.stop(chalk.green('company.getByCompanyUserId'));
+ printResult('[id, defaultLangId, rootCategoryId, name]', row);
+ continue;
+ }
+
+ if (action === 'category.getTree') {
+ const raw = await p.text({
+ message: 'Root category id',
+ placeholder: '1',
+ validate: (v) =>
+ !/^\d+$/.test(v?.trim() ?? '') ? 'Integer required' : undefined,
+ });
+ if (p.isCancel(raw)) continue;
+
+ const run = p.spinner();
+ run.start('Running…');
+ const tree = await client.category.getTree(Number(raw.trim()));
+ run.stop(chalk.green('category.getTree'));
+ printResult('tree', tree);
+ continue;
+ }
+
+ if (action === 'initialize') {
+ const run = p.spinner();
+ run.start('Initializing…');
+ await client.initialize();
+ run.stop(chalk.green('initialize'));
+ p.log.success('Schema purged and recreated');
+ console.log();
+ continue;
+ }
+
+ if (action === 'purge') {
+ const run = p.spinner();
+ run.start('Purging…');
+ await client.purge();
+ run.stop(chalk.green('purge'));
+ p.outro(chalk.red('Database purged — exiting'));
+ await shutdown(pool, 0);
+ }
+ } catch (err) {
+ p.log.error(err.message);
+ console.log(chalk.dim(err.stack?.split('\n').slice(1, 4).join('\n') ?? ''));
+ console.log();
+ }
+ }
+}
+
+main().catch(async (err) => {
+ console.error(chalk.red(err.stack ?? err.message));
+ process.exit(1);
+});
diff --git a/src/index.js b/src/index.js
new file mode 100644
index 0000000..e7a6c91
--- /dev/null
+++ b/src/index.js
@@ -0,0 +1,30 @@
+import companyuserGetByEmail from './store/companyuser/getByEmail.js';
+import companyGetByCompanyUserId from './store/company/getByCompanyUserId.js';
+import categoryGetTree from './store/category/getTree.js';
+import storePurge from './store/purge.js';
+import storeInitialize from './store/initialize.js';
+
+export default class LibDashStore {
+ constructor(pool) {
+ this.pool = pool;
+
+ this.companyuser = {
+ getByEmail: companyuserGetByEmail(pool),
+ };
+
+ this.company = {
+ getByCompanyUserId: companyGetByCompanyUserId(pool),
+ };
+
+ this.category = {
+ getTree: categoryGetTree(pool),
+ };
+
+ this.purge = storePurge(pool);
+ this.initialize = storeInitialize(pool);
+
+ return this.initialize().then(() => this);
+ }
+}
+
+export { SCHEMA_SCRIPTS } from './schema.js';
diff --git a/src/schema.js b/src/schema.js
index fc2382f..2504a9a 100644
--- a/src/schema.js
+++ b/src/schema.js
@@ -487,9 +487,10 @@ export const INIT_COMPANYUSER = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'companyuser')
BEGIN
CREATE TABLE companyuser (
+ id INT IDENTITY(1,1) PRIMARY KEY,
google_auth_id INT NOT NULL,
company_id INT NOT NULL,
- CONSTRAINT PK_companyuser PRIMARY KEY (google_auth_id, company_id),
+ CONSTRAINT UQ_companyuser_google_auth_company UNIQUE (google_auth_id, company_id),
CONSTRAINT FK_companyuser_google_auth FOREIGN KEY (google_auth_id) REFERENCES google_auth(id) ON DELETE CASCADE,
CONSTRAINT FK_companyuser_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE
);
diff --git a/src/sql/category.js b/src/sql/category.js
new file mode 100644
index 0000000..b602e38
--- /dev/null
+++ b/src/sql/category.js
@@ -0,0 +1,16 @@
+export const GET_TREE = `
+WITH tree AS (
+ SELECT id, parent_id, sort
+ FROM category
+ WHERE id = @rootId
+
+ UNION ALL
+
+ SELECT c.id, c.parent_id, c.sort
+ FROM category c
+ INNER JOIN tree t ON c.parent_id = t.id
+)
+SELECT id, parent_id, sort
+FROM tree
+ORDER BY sort, id
+`;
diff --git a/src/sql/company.js b/src/sql/company.js
new file mode 100644
index 0000000..0acdc63
--- /dev/null
+++ b/src/sql/company.js
@@ -0,0 +1,10 @@
+export const GET_BY_COMPANY_USER_ID = `
+SELECT
+ c.id,
+ c.default_language_id,
+ c.root_category_id,
+ c.name
+FROM company c
+INNER JOIN companyuser cu ON cu.company_id = c.id
+WHERE cu.id = @companyUserId
+`;
diff --git a/src/sql/companyuser.js b/src/sql/companyuser.js
new file mode 100644
index 0000000..7e5ca08
--- /dev/null
+++ b/src/sql/companyuser.js
@@ -0,0 +1,6 @@
+export const GET_BY_EMAIL = `
+SELECT cu.id
+FROM companyuser cu
+INNER JOIN google_auth ga ON ga.id = cu.google_auth_id
+WHERE ga.email = @email
+`;
diff --git a/src/sql/purge.js b/src/sql/purge.js
new file mode 100644
index 0000000..23647fe
--- /dev/null
+++ b/src/sql/purge.js
@@ -0,0 +1,49 @@
+/** Drop order = reverse of schema create dependency order. */
+export const TABLES = [
+ 'companyuser',
+ 'numbers',
+ 'manifestarticle',
+ 'manifest',
+ 'price',
+ 'articletext',
+ 'categorytext',
+ 'categorypic',
+ 'categoryarticle',
+ 'optionslistarticle',
+ 'articlelistarticle',
+ 'brandpic',
+ 'articlepic',
+ 'articleean',
+ 'articlehistory',
+ 'article',
+ 'brand',
+ 'articlelist',
+ 'optionslist',
+ 'unittext',
+ 'unit',
+ 'entity',
+ 'customergroup',
+ 'company',
+ 'pic',
+ 'category',
+ 'tax',
+ 'language',
+ 'google_auth',
+ 'meta',
+];
+
+export const PROCEDURES = ['getnextnumberwithprefixandpadding'];
+
+export function buildPurgeSql() {
+ const drops = [
+ ...PROCEDURES.map(
+ (name) =>
+ `IF OBJECT_ID(N'dbo.${name}', N'P') IS NOT NULL DROP PROCEDURE dbo.[${name}];`,
+ ),
+ ...TABLES.map(
+ (name) =>
+ `IF OBJECT_ID(N'dbo.${name}', N'U') IS NOT NULL DROP TABLE dbo.[${name}];`,
+ ),
+ ];
+ return drops.join('\n');
+}
diff --git a/src/store/category/getTree.js b/src/store/category/getTree.js
new file mode 100644
index 0000000..caafcb6
--- /dev/null
+++ b/src/store/category/getTree.js
@@ -0,0 +1,38 @@
+import sql from 'mssql';
+import { GET_TREE } from '../../sql/category.js';
+
+function buildTree(rows, rootId) {
+ const byId = new Map();
+ for (const row of rows) {
+ byId.set(row.id, {
+ id: row.id,
+ parent_id: row.parent_id,
+ sort: row.sort,
+ children: [],
+ });
+ }
+
+ let root = null;
+ for (const node of byId.values()) {
+ if (node.id === rootId) {
+ root = node;
+ continue;
+ }
+ const parent = byId.get(node.parent_id);
+ if (parent) parent.children.push(node);
+ }
+
+ return root;
+}
+
+export default function getTree(pool) {
+ return async (rootId) => {
+ const result = await pool
+ .request()
+ .input('rootId', sql.Int, rootId)
+ .query(GET_TREE);
+
+ if (result.recordset.length === 0) return null;
+ return buildTree(result.recordset, rootId);
+ };
+}
diff --git a/src/store/company/getByCompanyUserId.js b/src/store/company/getByCompanyUserId.js
new file mode 100644
index 0000000..a430f8d
--- /dev/null
+++ b/src/store/company/getByCompanyUserId.js
@@ -0,0 +1,16 @@
+import sql from 'mssql';
+import { GET_BY_COMPANY_USER_ID } from '../../sql/company.js';
+
+export default function getByCompanyUserId(pool) {
+ return async (companyUserId) => {
+ const result = await pool
+ .request()
+ .input('companyUserId', sql.Int, companyUserId)
+ .query(GET_BY_COMPANY_USER_ID);
+
+ const row = result.recordset[0];
+ if (!row) return null;
+
+ return [row.id, row.default_language_id, row.root_category_id, row.name];
+ };
+}
diff --git a/src/store/companyuser/getByEmail.js b/src/store/companyuser/getByEmail.js
new file mode 100644
index 0000000..171ff88
--- /dev/null
+++ b/src/store/companyuser/getByEmail.js
@@ -0,0 +1,13 @@
+import sql from 'mssql';
+import { GET_BY_EMAIL } from '../../sql/companyuser.js';
+
+export default function getByEmail(pool) {
+ return async (email) => {
+ const result = await pool
+ .request()
+ .input('email', sql.NVarChar(320), email)
+ .query(GET_BY_EMAIL);
+
+ return result.recordset[0]?.id ?? null;
+ };
+}
diff --git a/src/store/initialize.js b/src/store/initialize.js
new file mode 100644
index 0000000..7bd731a
--- /dev/null
+++ b/src/store/initialize.js
@@ -0,0 +1,12 @@
+import { SCHEMA_SCRIPTS } from '../schema.js';
+import purge from './purge.js';
+
+export default function initialize(pool) {
+ const doPurge = purge(pool);
+ return async () => {
+ await doPurge();
+ for (const script of SCHEMA_SCRIPTS) {
+ await pool.request().query(script);
+ }
+ };
+}
diff --git a/src/store/purge.js b/src/store/purge.js
new file mode 100644
index 0000000..b4bdafc
--- /dev/null
+++ b/src/store/purge.js
@@ -0,0 +1,7 @@
+import { buildPurgeSql } from '../sql/purge.js';
+
+export default function purge(pool) {
+ return async () => {
+ await pool.request().query(buildPurgeSql());
+ };
+}