nsa-sheets-db-builder 4.0.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/LICENSE +21 -0
- package/README.md +188 -0
- package/bin/sheets-deployer.mjs +169 -0
- package/libs/alasql.js +15577 -0
- package/libs/common/gas_response_helper.ts +147 -0
- package/libs/common/gaserror.ts +101 -0
- package/libs/common/gaslogger.ts +172 -0
- package/libs/db_ddl.ts +316 -0
- package/libs/libraries.json +56 -0
- package/libs/spreadsheets_db.ts +4406 -0
- package/libs/triggers.ts +113 -0
- package/package.json +73 -0
- package/scripts/build.mjs +513 -0
- package/scripts/clean.mjs +31 -0
- package/scripts/create.mjs +94 -0
- package/scripts/ddl-handler.mjs +232 -0
- package/scripts/describe.mjs +38 -0
- package/scripts/drop.mjs +39 -0
- package/scripts/init.mjs +465 -0
- package/scripts/lib/utils.mjs +1019 -0
- package/scripts/login.mjs +102 -0
- package/scripts/provision.mjs +35 -0
- package/scripts/refresh-cache.mjs +34 -0
- package/scripts/set-key.mjs +48 -0
- package/scripts/setup-trigger.mjs +95 -0
- package/scripts/setup.mjs +677 -0
- package/scripts/show.mjs +37 -0
- package/scripts/sync.mjs +35 -0
- package/scripts/whoami.mjs +36 -0
- package/src/api/ddl-handler-entry.ts +136 -0
- package/src/api/ddl.ts +321 -0
- package/src/templates/.clasp.json.ejs +1 -0
- package/src/templates/appsscript.json.ejs +16 -0
- package/src/templates/config.ts.ejs +14 -0
- package/src/templates/ddl-handler-config.ts.ejs +3 -0
- package/src/templates/ddl-handler-main.ts.ejs +56 -0
- package/src/templates/main.ts.ejs +288 -0
- package/src/templates/rbac.ts.ejs +148 -0
- package/src/templates/views.ts.ejs +92 -0
- package/templates/blank.json +33 -0
- package/templates/blog-cms.json +507 -0
- package/templates/crm.json +360 -0
- package/templates/e-commerce.json +424 -0
- package/templates/inventory.json +307 -0
package/libs/triggers.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System functions and table triggers for sheets-deployer.
|
|
3
|
+
*
|
|
4
|
+
* System (always included):
|
|
5
|
+
* - refreshTableCache: Mandatory — keeps __sys__tables__ cache warm
|
|
6
|
+
*
|
|
7
|
+
* Table triggers (defined per table in tables.json → "triggers"):
|
|
8
|
+
* - rebuildSearchIndex: Rebuild full-text search index in system spreadsheet
|
|
9
|
+
*
|
|
10
|
+
* Table trigger config example in tables.json:
|
|
11
|
+
* "contents": {
|
|
12
|
+
* "triggers": [
|
|
13
|
+
* { "function": "rebuildSearchIndex", "type": "time", "interval": "hours", "every": 6 }
|
|
14
|
+
* ]
|
|
15
|
+
* }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ── System (mandatory) ──────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Refresh the __sys__tables__ cache.
|
|
22
|
+
* Always deployed — keeps table metadata cache warm so reads are fast.
|
|
23
|
+
* Set up automatically during create; runs on a 1-hour interval.
|
|
24
|
+
*/
|
|
25
|
+
function refreshTableCache(): void {
|
|
26
|
+
const logger = typeof GASLoggerV2 !== 'undefined'
|
|
27
|
+
? new GASLoggerV2(DEPLOYER_CONFIG.name, null, LOGGING_VERBOSITY_LEVEL, '')
|
|
28
|
+
: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, getTraceId: () => '' };
|
|
29
|
+
|
|
30
|
+
const tables = typeof DB_SCHEMA !== 'undefined' ? DB_SCHEMA : {};
|
|
31
|
+
const db = new DBV2({
|
|
32
|
+
name: DEPLOYER_CONFIG.name,
|
|
33
|
+
systemSpreadsheetId: DEPLOYER_CONFIG.systemSpreadsheetId,
|
|
34
|
+
tables,
|
|
35
|
+
loggingVerbosity: DEPLOYER_CONFIG.loggingVerbosity
|
|
36
|
+
} as DBConfigV2, logger);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
db.refreshCache();
|
|
40
|
+
logger.info('refreshTableCache', 'Cache refreshed successfully');
|
|
41
|
+
} catch (err: any) {
|
|
42
|
+
logger.error(err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Table triggers (optional) ───────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Rebuild the full-text search index.
|
|
50
|
+
*
|
|
51
|
+
* Creates/updates a __search_index__ sheet in the system spreadsheet
|
|
52
|
+
* with flattened text from all searchable fields across all tables.
|
|
53
|
+
* Enables fast keyword search without scanning every row.
|
|
54
|
+
*/
|
|
55
|
+
function rebuildSearchIndex(): void {
|
|
56
|
+
const logger = typeof GASLoggerV2 !== 'undefined'
|
|
57
|
+
? new GASLoggerV2(DEPLOYER_CONFIG.name, null, LOGGING_VERBOSITY_LEVEL, '')
|
|
58
|
+
: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, getTraceId: () => '' };
|
|
59
|
+
|
|
60
|
+
const tables = typeof DB_SCHEMA !== 'undefined' ? DB_SCHEMA : {};
|
|
61
|
+
const db = new DBV2({
|
|
62
|
+
name: DEPLOYER_CONFIG.name,
|
|
63
|
+
systemSpreadsheetId: DEPLOYER_CONFIG.systemSpreadsheetId,
|
|
64
|
+
tables,
|
|
65
|
+
loggingVerbosity: DEPLOYER_CONFIG.loggingVerbosity
|
|
66
|
+
} as DBConfigV2, logger);
|
|
67
|
+
|
|
68
|
+
const indexRows: any[][] = [['entity_type', 'entity_id', 'text']];
|
|
69
|
+
let totalIndexed = 0;
|
|
70
|
+
|
|
71
|
+
for (const [tableName, tableDef] of Object.entries(tables) as any) {
|
|
72
|
+
try {
|
|
73
|
+
const table = db.table(tableName);
|
|
74
|
+
if (!table) continue;
|
|
75
|
+
|
|
76
|
+
const schema = tableDef.schema || {};
|
|
77
|
+
const textFields = Object.keys(schema).filter(f =>
|
|
78
|
+
schema[f].type === 'string' && !f.startsWith('__')
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (textFields.length === 0) continue;
|
|
82
|
+
|
|
83
|
+
const result = table.list({ filters: { __archived__: false } });
|
|
84
|
+
const rows = result?.data || [];
|
|
85
|
+
|
|
86
|
+
for (const row of rows) {
|
|
87
|
+
const textParts = textFields.map(f => row[f] || '').filter(Boolean);
|
|
88
|
+
if (textParts.length > 0) {
|
|
89
|
+
indexRows.push([tableName, row.id, textParts.join(' ')]);
|
|
90
|
+
totalIndexed++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
logger.warn('rebuildSearchIndex', `Error indexing ${tableName}`, { error: String(err) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const ss = SpreadsheetApp.openById(DEPLOYER_CONFIG.systemSpreadsheetId);
|
|
100
|
+
let sheet = ss.getSheetByName('__search_index__');
|
|
101
|
+
if (!sheet) {
|
|
102
|
+
sheet = ss.insertSheet('__search_index__');
|
|
103
|
+
}
|
|
104
|
+
sheet.clearContents();
|
|
105
|
+
if (indexRows.length > 0) {
|
|
106
|
+
sheet.getRange(1, 1, indexRows.length, 3).setValues(indexRows);
|
|
107
|
+
}
|
|
108
|
+
} catch (err: any) {
|
|
109
|
+
logger.warn('rebuildSearchIndex', 'Failed to write search index', { error: String(err) });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
logger.info('rebuildSearchIndex', `Indexed ${totalIndexed} record(s) from ${Object.keys(tables).length} table(s)`);
|
|
113
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nsa-sheets-db-builder",
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "DDL provisioner for Google Sheets — creates spreadsheets, sheets, and headers",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "NoStackApps",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/nostackapps/gas-tools.git",
|
|
11
|
+
"directory": "sheets-deployer"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/nostackapps/gas-tools/tree/main/sheets-deployer#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/nostackapps/gas-tools/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"google-sheets",
|
|
19
|
+
"google-apps-script",
|
|
20
|
+
"clasp",
|
|
21
|
+
"ddl",
|
|
22
|
+
"database",
|
|
23
|
+
"spreadsheet",
|
|
24
|
+
"provisioner",
|
|
25
|
+
"schema",
|
|
26
|
+
"cli"
|
|
27
|
+
],
|
|
28
|
+
"bin": {
|
|
29
|
+
"nsa-sheets-db-builder": "./bin/sheets-deployer.mjs"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"scripts/",
|
|
34
|
+
"src/",
|
|
35
|
+
"templates/",
|
|
36
|
+
"libs/",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.0.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"init": "node scripts/init.mjs",
|
|
45
|
+
"build": "node scripts/build.mjs",
|
|
46
|
+
"clean": "node scripts/clean.mjs",
|
|
47
|
+
"push": "node scripts/build.mjs --push",
|
|
48
|
+
"deploy": "node scripts/build.mjs --push --deploy",
|
|
49
|
+
"create": "node scripts/create.mjs",
|
|
50
|
+
"provision": "node scripts/provision.mjs",
|
|
51
|
+
"show": "node scripts/show.mjs",
|
|
52
|
+
"describe": "node scripts/describe.mjs",
|
|
53
|
+
"drop": "node scripts/drop.mjs",
|
|
54
|
+
"sync": "node scripts/sync.mjs",
|
|
55
|
+
"status": "node scripts/show.mjs --status",
|
|
56
|
+
"set-key": "node scripts/set-key.mjs",
|
|
57
|
+
"rotate-key": "node scripts/set-key.mjs --rotate",
|
|
58
|
+
"refresh-cache": "node scripts/refresh-cache.mjs",
|
|
59
|
+
"setup-trigger": "node scripts/setup-trigger.mjs",
|
|
60
|
+
"remove-trigger": "node scripts/setup-trigger.mjs --remove",
|
|
61
|
+
"login": "node scripts/login.mjs",
|
|
62
|
+
"whoami": "node scripts/whoami.mjs",
|
|
63
|
+
"setup": "node scripts/setup.mjs",
|
|
64
|
+
"ddl-handler": "node scripts/ddl-handler.mjs"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"ejs": "^3.1.10",
|
|
68
|
+
"esbuild": "^0.27.3"
|
|
69
|
+
},
|
|
70
|
+
"peerDependencies": {
|
|
71
|
+
"@google/clasp": ">=3.0.0"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build pipeline for nsa-sheets-db-builder (instance-aware)
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node scripts/build.mjs --db <name> [--env <env>] [--instance <type>] [--push] [--deploy]
|
|
8
|
+
*
|
|
9
|
+
* Without --instance: builds all instances for the environment.
|
|
10
|
+
* With --instance: builds only that instance.
|
|
11
|
+
* --push and --deploy require --instance (can't push all at once).
|
|
12
|
+
*
|
|
13
|
+
* Pipeline (per instance):
|
|
14
|
+
* 1. Load project.json + api.json
|
|
15
|
+
* 2. Clean/recreate dist/<name>/<instance>/
|
|
16
|
+
* 3. Process library files from dbs/<name>/libs/ → strip imports/exports → .js
|
|
17
|
+
* 4. Render config template (099) → .js (with instance_type)
|
|
18
|
+
* 5. Generate schema from tables.json (100) → .js
|
|
19
|
+
* 6. Process source files — ddl.ts (101), main.ts.ejs (102) → .js
|
|
20
|
+
* 7. Render .clasp.json + appsscript.json templates
|
|
21
|
+
* 8. Optional: clasp push / clasp deploy (requires --instance)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import { execSync } from 'child_process';
|
|
27
|
+
import ejs from 'ejs';
|
|
28
|
+
import { transformSync } from 'esbuild';
|
|
29
|
+
import {
|
|
30
|
+
parseArgs, requireDb, loadDbConfig, getEnvConfig,
|
|
31
|
+
getDistDirForInstance, getDistDir, getTablesPath, getLibsDir,
|
|
32
|
+
getAllInstances, requireInstance, loadTables,
|
|
33
|
+
loadRbacConfig, loadViewsConfig, loadCustomMethodsConfig, getFeatures, validateRbacRequirements,
|
|
34
|
+
getDdlHandlerDistDir, getRootEnvConfig,
|
|
35
|
+
PACKAGE_ROOT, COMMON_LIBS_DIR, DBS_DIR, ensureAuthenticated
|
|
36
|
+
} from './lib/utils.mjs';
|
|
37
|
+
|
|
38
|
+
// ────────────────────────────────────────
|
|
39
|
+
// Instance type → short suffix for deployment names
|
|
40
|
+
// ────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Build the --user flag string (empty when using default credentials). */
|
|
43
|
+
function claspUserFlag(profile) {
|
|
44
|
+
return profile ? ` --user ${profile}` : '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const TYPE_SUFFIX = {
|
|
48
|
+
'read-write': '[RW]',
|
|
49
|
+
'read-only': '[R]',
|
|
50
|
+
'write-only': '[W]'
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function getDeploymentDescription(dbName, instanceType, env) {
|
|
54
|
+
const suffix = TYPE_SUFFIX[instanceType] || `[${instanceType}]`;
|
|
55
|
+
return `${dbName} ${env} ${suffix}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ────────────────────────────────────────
|
|
59
|
+
// Strip imports/exports for GAS compatibility
|
|
60
|
+
// ────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function stripImportsExports(content) {
|
|
63
|
+
const lines = content.split('\n');
|
|
64
|
+
const result = [];
|
|
65
|
+
|
|
66
|
+
for (let line of lines) {
|
|
67
|
+
if (/^\s*import\s+/.test(line)) continue;
|
|
68
|
+
if (/^\s*export\s*\{/.test(line)) continue;
|
|
69
|
+
|
|
70
|
+
if (/^\s*export\s+(const|let|var|function|class|interface|type|enum)/.test(line)) {
|
|
71
|
+
line = line.replace(/^\s*export\s+/, '');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (/^\s*export\s+default\s+/.test(line)) {
|
|
75
|
+
line = line.replace(/^\s*export\s+default\s+/, '');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
result.push(line);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result.join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Strip TypeScript type annotations using esbuild.
|
|
86
|
+
*/
|
|
87
|
+
function stripTypeAnnotations(content) {
|
|
88
|
+
const result = transformSync(content, {
|
|
89
|
+
loader: 'ts',
|
|
90
|
+
target: 'es2020',
|
|
91
|
+
minify: false,
|
|
92
|
+
sourcemap: false,
|
|
93
|
+
});
|
|
94
|
+
return result.code;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ────────────────────────────────────────
|
|
98
|
+
// Build a single instance
|
|
99
|
+
// ────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
export function buildInstance(dbName, config, env, envConfig, instance, features, rbacConfig, viewsConfig, customMethodsConfig) {
|
|
102
|
+
const { scriptId, type: instanceType } = instance;
|
|
103
|
+
const distDir = getDistDirForInstance(dbName, instanceType);
|
|
104
|
+
|
|
105
|
+
console.log(`\n Building instance: ${instanceType} (${scriptId.slice(0, 16)}...)`);
|
|
106
|
+
|
|
107
|
+
// Clean and recreate
|
|
108
|
+
if (fs.existsSync(distDir)) {
|
|
109
|
+
fs.rmSync(distDir, { recursive: true });
|
|
110
|
+
}
|
|
111
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
112
|
+
|
|
113
|
+
const templateDir = path.join(PACKAGE_ROOT, 'src', 'templates');
|
|
114
|
+
const dbDir = path.join(DBS_DIR, dbName);
|
|
115
|
+
|
|
116
|
+
// 1. Libraries from project's libs/ directory (sorted alphabetically = numeric order)
|
|
117
|
+
const libsDir = getLibsDir(dbName);
|
|
118
|
+
let fileIndex = 0;
|
|
119
|
+
|
|
120
|
+
if (fs.existsSync(libsDir)) {
|
|
121
|
+
const libFiles = fs.readdirSync(libsDir)
|
|
122
|
+
.filter(f => f.endsWith('.ts') || f.endsWith('.js'))
|
|
123
|
+
.sort();
|
|
124
|
+
|
|
125
|
+
for (const libFile of libFiles) {
|
|
126
|
+
const content = fs.readFileSync(path.join(libsDir, libFile), 'utf8');
|
|
127
|
+
let processed = stripImportsExports(content);
|
|
128
|
+
if (libFile.endsWith('.ts')) {
|
|
129
|
+
processed = stripTypeAnnotations(processed);
|
|
130
|
+
}
|
|
131
|
+
const outName = `${String(fileIndex).padStart(3, '0')}_lib_${path.parse(libFile).name}.js`;
|
|
132
|
+
fs.writeFileSync(path.join(distDir, outName), processed);
|
|
133
|
+
console.log(` ${outName}`);
|
|
134
|
+
fileIndex++;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. Config (099) — rendered with instance_type
|
|
139
|
+
const configTemplatePath = path.join(templateDir, 'config.ts.ejs');
|
|
140
|
+
if (fs.existsSync(configTemplatePath)) {
|
|
141
|
+
const configTemplate = fs.readFileSync(configTemplatePath, 'utf8');
|
|
142
|
+
const configContent = ejs.render(configTemplate, {
|
|
143
|
+
env: env,
|
|
144
|
+
instance_type: instanceType,
|
|
145
|
+
logging_verbosity: config.settings?.loggingVerbosity ?? 2,
|
|
146
|
+
config_name: config.name,
|
|
147
|
+
system_spreadsheet_id: envConfig.systemSpreadsheetId || '',
|
|
148
|
+
drive_folder_id: envConfig.driveFolderId || '',
|
|
149
|
+
ddl_admin_key: envConfig.ddlAdminKey || ''
|
|
150
|
+
});
|
|
151
|
+
const configOut = '099_config.js';
|
|
152
|
+
fs.writeFileSync(path.join(distDir, configOut), configContent);
|
|
153
|
+
console.log(` ${configOut}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 3. Schema (100) — generated from tables.json
|
|
157
|
+
const tablesPath = getTablesPath(dbName);
|
|
158
|
+
if (fs.existsSync(tablesPath)) {
|
|
159
|
+
const tablesJson = fs.readFileSync(tablesPath, 'utf8');
|
|
160
|
+
const schemaJs = `var DB_SCHEMA = ${tablesJson.trim()};\n`;
|
|
161
|
+
const schemaOut = '100_schema.js';
|
|
162
|
+
fs.writeFileSync(path.join(distDir, schemaOut), schemaJs);
|
|
163
|
+
console.log(` ${schemaOut}`);
|
|
164
|
+
} else {
|
|
165
|
+
console.error(`ERROR: tables.json not found at ${tablesPath}`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 4. DDL (101) — static source file
|
|
170
|
+
const ddlPath = path.join(PACKAGE_ROOT, 'src', 'api', 'ddl.ts');
|
|
171
|
+
if (fs.existsSync(ddlPath)) {
|
|
172
|
+
const content = fs.readFileSync(ddlPath, 'utf8');
|
|
173
|
+
let processed = stripImportsExports(content);
|
|
174
|
+
processed = stripTypeAnnotations(processed);
|
|
175
|
+
const outName = '101_api_ddl.js';
|
|
176
|
+
fs.writeFileSync(path.join(distDir, outName), processed);
|
|
177
|
+
console.log(` ${outName}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 5. RBAC middleware (102) — conditional
|
|
181
|
+
if (features.rbac.enabled && rbacConfig) {
|
|
182
|
+
const rbacTemplatePath = path.join(templateDir, 'rbac.ts.ejs');
|
|
183
|
+
if (fs.existsSync(rbacTemplatePath)) {
|
|
184
|
+
const rbacTemplate = fs.readFileSync(rbacTemplatePath, 'utf8');
|
|
185
|
+
let rbacContent = ejs.render(rbacTemplate, {
|
|
186
|
+
auth_mode: features.authMode,
|
|
187
|
+
rbac_config: rbacConfig
|
|
188
|
+
});
|
|
189
|
+
rbacContent = stripTypeAnnotations(rbacContent);
|
|
190
|
+
const rbacOut = '102_rbac.js';
|
|
191
|
+
fs.writeFileSync(path.join(distDir, rbacOut), rbacContent);
|
|
192
|
+
console.log(` ${rbacOut} (RBAC: ${features.authMode} auth)`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 6. Views runtime (103) + alasql (104) — conditional
|
|
197
|
+
const viewNames = viewsConfig ? Object.keys(viewsConfig) : [];
|
|
198
|
+
if (features.views.enabled && viewsConfig && viewNames.length > 0) {
|
|
199
|
+
const viewsTemplatePath = path.join(templateDir, 'views.ts.ejs');
|
|
200
|
+
if (fs.existsSync(viewsTemplatePath)) {
|
|
201
|
+
const viewsTemplate = fs.readFileSync(viewsTemplatePath, 'utf8');
|
|
202
|
+
let viewsContent = ejs.render(viewsTemplate, { views_config: viewsConfig });
|
|
203
|
+
viewsContent = stripTypeAnnotations(viewsContent);
|
|
204
|
+
const viewsOut = '103_views.js';
|
|
205
|
+
fs.writeFileSync(path.join(distDir, viewsOut), viewsContent);
|
|
206
|
+
console.log(` ${viewsOut} (${viewNames.length} view(s))`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// alasql — should already be in project libs (scaffolded during init)
|
|
210
|
+
// If not in project libs, fall back to package libs/
|
|
211
|
+
const projectAlasql = fs.readdirSync(distDir).find(f => f.includes('alasql'));
|
|
212
|
+
if (!projectAlasql) {
|
|
213
|
+
const alasqlPath = path.join(PACKAGE_ROOT, 'libs', 'alasql.js');
|
|
214
|
+
if (fs.existsSync(alasqlPath)) {
|
|
215
|
+
const alasqlOut = '104_alasql.js';
|
|
216
|
+
fs.copyFileSync(alasqlPath, path.join(distDir, alasqlOut));
|
|
217
|
+
console.log(` ${alasqlOut} (alasql runtime — fallback from package)`);
|
|
218
|
+
} else {
|
|
219
|
+
console.error(' ERROR: alasql not found — views require the alasql GAS fork');
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 7. Custom method handlers — declared in customMethods.json, files in overrides/
|
|
226
|
+
const overridesDir = path.join(dbDir, 'overrides');
|
|
227
|
+
const customMethods = [];
|
|
228
|
+
const declaredMethods = customMethodsConfig || {};
|
|
229
|
+
|
|
230
|
+
let methodIdx = 105;
|
|
231
|
+
for (const [actionName, methodDef] of Object.entries(declaredMethods).sort(([a], [b]) => a.localeCompare(b))) {
|
|
232
|
+
// Resolve handler file: explicit path or convention overrides/<action>.ts
|
|
233
|
+
const handler = methodDef.handler || `${actionName}.ts`;
|
|
234
|
+
const handlerPath = path.isAbsolute(handler)
|
|
235
|
+
? handler
|
|
236
|
+
: path.join(overridesDir, handler);
|
|
237
|
+
|
|
238
|
+
if (!fs.existsSync(handlerPath)) {
|
|
239
|
+
console.warn(` Warning: custom method "${actionName}" handler not found: ${handlerPath}`);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const content = fs.readFileSync(handlerPath, 'utf8');
|
|
244
|
+
let processed = stripImportsExports(content);
|
|
245
|
+
if (handlerPath.endsWith('.ts')) {
|
|
246
|
+
processed = stripTypeAnnotations(processed);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
customMethods.push(actionName);
|
|
250
|
+
|
|
251
|
+
const outName = `${String(methodIdx).padStart(3, '0')}_method_${actionName}.js`;
|
|
252
|
+
fs.writeFileSync(path.join(distDir, outName), processed);
|
|
253
|
+
console.log(` ${outName} (custom method: ${actionName})`);
|
|
254
|
+
methodIdx++;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 8. Main (110) — rendered from EJS template with all feature flags + methods
|
|
258
|
+
const mainTemplatePath = path.join(templateDir, 'main.ts.ejs');
|
|
259
|
+
if (fs.existsSync(mainTemplatePath)) {
|
|
260
|
+
const mainTemplate = fs.readFileSync(mainTemplatePath, 'utf8');
|
|
261
|
+
let mainContent = ejs.render(mainTemplate, {
|
|
262
|
+
instance_type: instanceType,
|
|
263
|
+
auth_mode: features.authMode,
|
|
264
|
+
rbac_enabled: features.rbac.enabled && !!rbacConfig,
|
|
265
|
+
views_enabled: features.views.enabled && viewNames.length > 0,
|
|
266
|
+
view_names: viewNames,
|
|
267
|
+
custom_methods: customMethods
|
|
268
|
+
});
|
|
269
|
+
mainContent = stripTypeAnnotations(mainContent);
|
|
270
|
+
const mainOut = '110_api_main.js';
|
|
271
|
+
fs.writeFileSync(path.join(distDir, mainOut), mainContent);
|
|
272
|
+
console.log(` ${mainOut}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 9. Manifests
|
|
276
|
+
const claspTemplate = fs.readFileSync(path.join(templateDir, '.clasp.json.ejs'), 'utf8');
|
|
277
|
+
const claspContent = ejs.render(claspTemplate, { script_id: scriptId, project_id: envConfig.projectId || getRootEnvConfig(env).projectId || '' });
|
|
278
|
+
fs.writeFileSync(path.join(distDir, '.clasp.json'), claspContent);
|
|
279
|
+
console.log(' .clasp.json');
|
|
280
|
+
|
|
281
|
+
const appsscriptTemplate = fs.readFileSync(path.join(templateDir, 'appsscript.json.ejs'), 'utf8');
|
|
282
|
+
const appsscriptContent = ejs.render(appsscriptTemplate, {});
|
|
283
|
+
fs.writeFileSync(path.join(distDir, 'appsscript.json'), appsscriptContent);
|
|
284
|
+
console.log(' appsscript.json');
|
|
285
|
+
|
|
286
|
+
const fileCount = fs.readdirSync(distDir).length;
|
|
287
|
+
console.log(` → ${fileCount} files`);
|
|
288
|
+
|
|
289
|
+
return distDir;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ────────────────────────────────────────
|
|
293
|
+
// Build DDL Handler (per-account, shared)
|
|
294
|
+
// ────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Build the DDL handler project — a lightweight, schema-agnostic web app.
|
|
298
|
+
*
|
|
299
|
+
* Output (dist/__ddl-handler__/):
|
|
300
|
+
* 000_lib_gaserror.js ← from common/libs/gaserror.ts
|
|
301
|
+
* 001_lib_gaslogger.js ← from common/libs/gaslogger.ts
|
|
302
|
+
* 002_lib_db_ddl.js ← from libs/db_ddl.ts
|
|
303
|
+
* 099_config.js ← ddl-handler-config.ts.ejs
|
|
304
|
+
* 101_api_ddl.js ← ddl-handler-entry.ts (generic funcs only)
|
|
305
|
+
* 110_ddl_main.js ← ddl-handler-main.ts.ejs
|
|
306
|
+
* .clasp.json
|
|
307
|
+
* appsscript.json
|
|
308
|
+
*
|
|
309
|
+
* @param {string} ddlAdminKey - Admin key for DDL handler authentication
|
|
310
|
+
* @param {number} loggingVerbosity - Logging verbosity level (default 2)
|
|
311
|
+
* @param {string} [scriptId] - Script ID for .clasp.json (optional, set later if creating)
|
|
312
|
+
* @param {string} [projectId] - GCP project ID (optional)
|
|
313
|
+
* @returns {string} Path to the dist directory
|
|
314
|
+
*/
|
|
315
|
+
export function buildDdlHandler(ddlAdminKey, loggingVerbosity = 2, scriptId = '', projectId = '') {
|
|
316
|
+
const distDir = getDdlHandlerDistDir();
|
|
317
|
+
|
|
318
|
+
console.log(`\n Building DDL handler → dist/__ddl-handler__/`);
|
|
319
|
+
|
|
320
|
+
// Clean and recreate
|
|
321
|
+
if (fs.existsSync(distDir)) {
|
|
322
|
+
fs.rmSync(distDir, { recursive: true });
|
|
323
|
+
}
|
|
324
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
325
|
+
|
|
326
|
+
const templateDir = path.join(PACKAGE_ROOT, 'src', 'templates');
|
|
327
|
+
|
|
328
|
+
// 1. Bundle libs: gaserror.ts → gaslogger.ts → db_ddl.ts
|
|
329
|
+
const libSources = [
|
|
330
|
+
{ src: path.join(COMMON_LIBS_DIR, 'gaserror.ts'), out: '000_lib_gaserror.js' },
|
|
331
|
+
{ src: path.join(COMMON_LIBS_DIR, 'gaslogger.ts'), out: '001_lib_gaslogger.js' },
|
|
332
|
+
{ src: path.join(PACKAGE_ROOT, 'libs', 'db_ddl.ts'), out: '002_lib_db_ddl.js' }
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
for (const { src, out } of libSources) {
|
|
336
|
+
if (!fs.existsSync(src)) {
|
|
337
|
+
console.error(` ERROR: library not found: ${src}`);
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
const content = fs.readFileSync(src, 'utf8');
|
|
341
|
+
let processed = stripImportsExports(content);
|
|
342
|
+
processed = stripTypeAnnotations(processed);
|
|
343
|
+
fs.writeFileSync(path.join(distDir, out), processed);
|
|
344
|
+
console.log(` ${out}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 2. Render config: ddl-handler-config.ts.ejs → 099_config.js
|
|
348
|
+
const configTemplatePath = path.join(templateDir, 'ddl-handler-config.ts.ejs');
|
|
349
|
+
const configTemplate = fs.readFileSync(configTemplatePath, 'utf8');
|
|
350
|
+
const configContent = ejs.render(configTemplate, {
|
|
351
|
+
ddl_admin_key: ddlAdminKey,
|
|
352
|
+
logging_verbosity: loggingVerbosity
|
|
353
|
+
});
|
|
354
|
+
fs.writeFileSync(path.join(distDir, '099_config.js'), configContent);
|
|
355
|
+
console.log(' 099_config.js');
|
|
356
|
+
|
|
357
|
+
// 3. Process DDL entry points: ddl-handler-entry.ts → 101_api_ddl.js
|
|
358
|
+
const ddlEntryPath = path.join(PACKAGE_ROOT, 'src', 'api', 'ddl-handler-entry.ts');
|
|
359
|
+
if (!fs.existsSync(ddlEntryPath)) {
|
|
360
|
+
console.error(` ERROR: DDL handler entry not found: ${ddlEntryPath}`);
|
|
361
|
+
process.exit(1);
|
|
362
|
+
}
|
|
363
|
+
let ddlContent = fs.readFileSync(ddlEntryPath, 'utf8');
|
|
364
|
+
ddlContent = stripImportsExports(ddlContent);
|
|
365
|
+
ddlContent = stripTypeAnnotations(ddlContent);
|
|
366
|
+
fs.writeFileSync(path.join(distDir, '101_api_ddl.js'), ddlContent);
|
|
367
|
+
console.log(' 101_api_ddl.js');
|
|
368
|
+
|
|
369
|
+
// 4. Render main: ddl-handler-main.ts.ejs → 110_ddl_main.js
|
|
370
|
+
const mainTemplatePath = path.join(templateDir, 'ddl-handler-main.ts.ejs');
|
|
371
|
+
const mainTemplate = fs.readFileSync(mainTemplatePath, 'utf8');
|
|
372
|
+
let mainContent = ejs.render(mainTemplate, {});
|
|
373
|
+
mainContent = stripTypeAnnotations(mainContent);
|
|
374
|
+
fs.writeFileSync(path.join(distDir, '110_ddl_main.js'), mainContent);
|
|
375
|
+
console.log(' 110_ddl_main.js');
|
|
376
|
+
|
|
377
|
+
// 5. Render manifests
|
|
378
|
+
if (scriptId) {
|
|
379
|
+
const claspTemplate = fs.readFileSync(path.join(templateDir, '.clasp.json.ejs'), 'utf8');
|
|
380
|
+
const claspContent = ejs.render(claspTemplate, { script_id: scriptId, project_id: projectId || '' });
|
|
381
|
+
fs.writeFileSync(path.join(distDir, '.clasp.json'), claspContent);
|
|
382
|
+
console.log(' .clasp.json');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const appsscriptTemplate = fs.readFileSync(path.join(templateDir, 'appsscript.json.ejs'), 'utf8');
|
|
386
|
+
const appsscriptContent = ejs.render(appsscriptTemplate, {});
|
|
387
|
+
fs.writeFileSync(path.join(distDir, 'appsscript.json'), appsscriptContent);
|
|
388
|
+
console.log(' appsscript.json');
|
|
389
|
+
|
|
390
|
+
const fileCount = fs.readdirSync(distDir).length;
|
|
391
|
+
console.log(` → ${fileCount} files`);
|
|
392
|
+
|
|
393
|
+
return distDir;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ────────────────────────────────────────
|
|
397
|
+
// Main
|
|
398
|
+
// ────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
async function build() {
|
|
401
|
+
const args = parseArgs();
|
|
402
|
+
const dbName = requireDb(args, 'build.mjs');
|
|
403
|
+
|
|
404
|
+
const config = loadDbConfig(dbName);
|
|
405
|
+
const { env, envConfig } = getEnvConfig(config, args.env);
|
|
406
|
+
const features = getFeatures(config);
|
|
407
|
+
|
|
408
|
+
console.log(`Building ${dbName} for environment: ${env}`);
|
|
409
|
+
|
|
410
|
+
// Load optional feature configs
|
|
411
|
+
let rbacConfig = null;
|
|
412
|
+
let viewsConfig = null;
|
|
413
|
+
const customMethodsConfig = loadCustomMethodsConfig(dbName);
|
|
414
|
+
const methodCount = Object.keys(customMethodsConfig).length;
|
|
415
|
+
if (methodCount > 0) {
|
|
416
|
+
console.log(` Custom methods: ${methodCount} declared`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (features.rbac.enabled) {
|
|
420
|
+
rbacConfig = loadRbacConfig(dbName);
|
|
421
|
+
if (!rbacConfig) {
|
|
422
|
+
console.error('RBAC enabled in project.json but rbac.json not found.');
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
const tables = loadTables(dbName);
|
|
426
|
+
const errors = validateRbacRequirements(tables, rbacConfig);
|
|
427
|
+
if (errors.length > 0) {
|
|
428
|
+
console.error('RBAC validation failed:');
|
|
429
|
+
for (const err of errors) console.error(` - ${err}`);
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
console.log(` RBAC: enabled (${features.authMode} auth, ${Object.keys(rbacConfig.roles).length} role(s))`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (features.views.enabled) {
|
|
436
|
+
viewsConfig = loadViewsConfig(dbName);
|
|
437
|
+
if (!viewsConfig) {
|
|
438
|
+
console.error('Views enabled in project.json but views.json not found.');
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
console.log(` Views: enabled (${Object.keys(viewsConfig).length} view(s))`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Determine which instances to build
|
|
445
|
+
const instances = args.instance
|
|
446
|
+
? [requireInstance(args, envConfig)]
|
|
447
|
+
: getAllInstances(envConfig);
|
|
448
|
+
|
|
449
|
+
if (instances.length === 0) {
|
|
450
|
+
console.error('No instances configured. Run init with --instances to set up instances.');
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Clean top-level dist/<db>/ first (remove stale instance dirs)
|
|
455
|
+
const topDistDir = getDistDir(dbName);
|
|
456
|
+
if (fs.existsSync(topDistDir)) {
|
|
457
|
+
fs.rmSync(topDistDir, { recursive: true });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Build each instance
|
|
461
|
+
for (const inst of instances) {
|
|
462
|
+
buildInstance(dbName, config, env, envConfig, inst, features, rbacConfig, viewsConfig, customMethodsConfig);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
console.log(`\nBuild complete → dist/${dbName}/`);
|
|
466
|
+
|
|
467
|
+
// Push/deploy require --instance
|
|
468
|
+
if (args.push || args.deploy) {
|
|
469
|
+
if (!args.instance && instances.length > 1) {
|
|
470
|
+
console.error('\n--push and --deploy require --instance when multiple instances exist.');
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const inst = requireInstance(args, envConfig);
|
|
475
|
+
const distDir = getDistDirForInstance(dbName, inst.type);
|
|
476
|
+
const userProfile = await ensureAuthenticated(dbName, args, envConfig, env);
|
|
477
|
+
|
|
478
|
+
if (args.push || args.deploy) {
|
|
479
|
+
console.log(`\nPushing ${inst.type} to Google Apps Script...`);
|
|
480
|
+
try {
|
|
481
|
+
execSync(`npx --prefer-offline @google/clasp push${claspUserFlag(userProfile)}`, { cwd: distDir, stdio: 'inherit' });
|
|
482
|
+
console.log('Push complete.');
|
|
483
|
+
} catch {
|
|
484
|
+
console.error('clasp push failed.');
|
|
485
|
+
process.exit(1);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (args.deploy) {
|
|
490
|
+
const desc = getDeploymentDescription(dbName, inst.type, env);
|
|
491
|
+
console.log(`\nDeploying ${inst.type} as "${desc}"...`);
|
|
492
|
+
try {
|
|
493
|
+
if (inst.deploymentId) {
|
|
494
|
+
execSync(`npx --prefer-offline @google/clasp update-deployment ${inst.deploymentId} --description '${desc}'${claspUserFlag(userProfile)}`, { cwd: distDir, stdio: 'inherit' });
|
|
495
|
+
console.log(`Deployment updated: ${inst.deploymentId}`);
|
|
496
|
+
} else {
|
|
497
|
+
execSync(`npx --prefer-offline @google/clasp deploy --description '${desc}'${claspUserFlag(userProfile)}`, { cwd: distDir, stdio: 'inherit' });
|
|
498
|
+
console.log('New deployment created.');
|
|
499
|
+
}
|
|
500
|
+
} catch {
|
|
501
|
+
console.error('clasp deploy failed.');
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Only run when executed directly (not when imported)
|
|
509
|
+
import { fileURLToPath } from 'url';
|
|
510
|
+
const __build_filename = fileURLToPath(import.meta.url);
|
|
511
|
+
if (process.argv[1] === __build_filename) {
|
|
512
|
+
build();
|
|
513
|
+
}
|