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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/bin/sheets-deployer.mjs +169 -0
  4. package/libs/alasql.js +15577 -0
  5. package/libs/common/gas_response_helper.ts +147 -0
  6. package/libs/common/gaserror.ts +101 -0
  7. package/libs/common/gaslogger.ts +172 -0
  8. package/libs/db_ddl.ts +316 -0
  9. package/libs/libraries.json +56 -0
  10. package/libs/spreadsheets_db.ts +4406 -0
  11. package/libs/triggers.ts +113 -0
  12. package/package.json +73 -0
  13. package/scripts/build.mjs +513 -0
  14. package/scripts/clean.mjs +31 -0
  15. package/scripts/create.mjs +94 -0
  16. package/scripts/ddl-handler.mjs +232 -0
  17. package/scripts/describe.mjs +38 -0
  18. package/scripts/drop.mjs +39 -0
  19. package/scripts/init.mjs +465 -0
  20. package/scripts/lib/utils.mjs +1019 -0
  21. package/scripts/login.mjs +102 -0
  22. package/scripts/provision.mjs +35 -0
  23. package/scripts/refresh-cache.mjs +34 -0
  24. package/scripts/set-key.mjs +48 -0
  25. package/scripts/setup-trigger.mjs +95 -0
  26. package/scripts/setup.mjs +677 -0
  27. package/scripts/show.mjs +37 -0
  28. package/scripts/sync.mjs +35 -0
  29. package/scripts/whoami.mjs +36 -0
  30. package/src/api/ddl-handler-entry.ts +136 -0
  31. package/src/api/ddl.ts +321 -0
  32. package/src/templates/.clasp.json.ejs +1 -0
  33. package/src/templates/appsscript.json.ejs +16 -0
  34. package/src/templates/config.ts.ejs +14 -0
  35. package/src/templates/ddl-handler-config.ts.ejs +3 -0
  36. package/src/templates/ddl-handler-main.ts.ejs +56 -0
  37. package/src/templates/main.ts.ejs +288 -0
  38. package/src/templates/rbac.ts.ejs +148 -0
  39. package/src/templates/views.ts.ejs +92 -0
  40. package/templates/blank.json +33 -0
  41. package/templates/blog-cms.json +507 -0
  42. package/templates/crm.json +360 -0
  43. package/templates/e-commerce.json +424 -0
  44. package/templates/inventory.json +307 -0
@@ -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
+ }