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/scripts/show.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Show tables in data spreadsheets.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node scripts/show.mjs --db <name> [--env <env>] [--instance <type>]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { parseArgs, requireDb, loadDbConfig, getEnvConfig, getDistDirForInstance, claspRun, loadTables, requireDataSpreadsheet, requireInstance, ensureAuthenticated } from './lib/utils.mjs';
|
|
11
|
+
|
|
12
|
+
const args = parseArgs();
|
|
13
|
+
const dbName = requireDb(args, 'show.mjs');
|
|
14
|
+
const config = loadDbConfig(dbName);
|
|
15
|
+
const { env, envConfig } = getEnvConfig(config, args.env);
|
|
16
|
+
const instance = requireInstance(args, envConfig);
|
|
17
|
+
const distDir = getDistDirForInstance(dbName, instance.type);
|
|
18
|
+
|
|
19
|
+
const tables = loadTables(dbName);
|
|
20
|
+
const spreadsheetId = requireDataSpreadsheet(tables, dbName);
|
|
21
|
+
|
|
22
|
+
// Auth enforcement
|
|
23
|
+
const userProfile = await ensureAuthenticated(dbName, args, envConfig, env);
|
|
24
|
+
|
|
25
|
+
console.log(`Showing tables for ${dbName} (${env}, ${instance.type})...`);
|
|
26
|
+
console.log(` Data spreadsheet: ${spreadsheetId}`);
|
|
27
|
+
|
|
28
|
+
const result = claspRun(distDir, 'ddlShowTables', [{ spreadsheetId }], { userProfile });
|
|
29
|
+
|
|
30
|
+
if (Array.isArray(result)) {
|
|
31
|
+
console.log(`\nTables (${result.length}):`);
|
|
32
|
+
for (const table of result) {
|
|
33
|
+
console.log(` - ${table}`);
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
console.log('\nResult:', JSON.stringify(result, null, 2));
|
|
37
|
+
}
|
package/scripts/sync.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sync schema — detect drift between tables.json and the live data spreadsheet.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node scripts/sync.mjs --db <name> [--env <env>] [--instance <type>]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { parseArgs, requireDb, loadDbConfig, getEnvConfig, getDistDirForInstance, claspRun, loadTables, requireDataSpreadsheet, requireInstance, ensureAuthenticated } from './lib/utils.mjs';
|
|
11
|
+
|
|
12
|
+
const args = parseArgs();
|
|
13
|
+
const dbName = requireDb(args, 'sync.mjs');
|
|
14
|
+
const config = loadDbConfig(dbName);
|
|
15
|
+
const { env, envConfig } = getEnvConfig(config, args.env);
|
|
16
|
+
const instance = requireInstance(args, envConfig);
|
|
17
|
+
const distDir = getDistDirForInstance(dbName, instance.type);
|
|
18
|
+
|
|
19
|
+
const tables = loadTables(dbName);
|
|
20
|
+
const spreadsheetId = requireDataSpreadsheet(tables, dbName);
|
|
21
|
+
const tableNames = Object.keys(tables);
|
|
22
|
+
|
|
23
|
+
// Auth enforcement
|
|
24
|
+
const userProfile = await ensureAuthenticated(dbName, args, envConfig, env);
|
|
25
|
+
|
|
26
|
+
console.log(`Syncing schema for ${dbName} (${env}, ${instance.type})...`);
|
|
27
|
+
console.log(` Tables in schema: ${tableNames.join(', ')}`);
|
|
28
|
+
console.log(` Data spreadsheet: ${spreadsheetId}`);
|
|
29
|
+
|
|
30
|
+
const result = claspRun(distDir, 'ddlSyncSchema', [{
|
|
31
|
+
spreadsheetId,
|
|
32
|
+
tables
|
|
33
|
+
}], { userProfile });
|
|
34
|
+
|
|
35
|
+
console.log('\nResult:', JSON.stringify(result, null, 2));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check current Google account for a DB project.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node scripts/whoami.mjs --db <name> [--env <env>]
|
|
8
|
+
*
|
|
9
|
+
* Shows the logged-in account for the DB's clasp user profile.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { parseArgs, requireDb, loadDbConfig, getEnvConfig, getClaspUserProfile, hasClaspProfile, getAccountEmail } from './lib/utils.mjs';
|
|
13
|
+
|
|
14
|
+
const args = parseArgs();
|
|
15
|
+
const dbName = requireDb(args, 'whoami.mjs');
|
|
16
|
+
const config = loadDbConfig(dbName);
|
|
17
|
+
const { env } = getEnvConfig(config, args.env);
|
|
18
|
+
|
|
19
|
+
const profile = getClaspUserProfile(dbName, env);
|
|
20
|
+
|
|
21
|
+
if (!hasClaspProfile(profile)) {
|
|
22
|
+
console.log(`No credentials found for profile "${profile}".`);
|
|
23
|
+
console.log(`Run: npm run login -- --db ${dbName} --env ${env}`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const email = await getAccountEmail(profile);
|
|
28
|
+
|
|
29
|
+
if (email) {
|
|
30
|
+
console.log(`Logged in as: ${email}`);
|
|
31
|
+
console.log(` Profile: ${profile}`);
|
|
32
|
+
console.log(` Environment: ${env}`);
|
|
33
|
+
} else {
|
|
34
|
+
console.log(`Profile "${profile}" exists but could not verify account.`);
|
|
35
|
+
console.log(`Token may be expired. Run: npm run login -- --db ${dbName} --env ${env}`);
|
|
36
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// ========================================
|
|
2
|
+
// DDL Handler — Entry Points (generic, schema-agnostic)
|
|
3
|
+
//
|
|
4
|
+
// These functions run in the shared DDL handler (one per Google account).
|
|
5
|
+
// They only need DriveApp + SpreadsheetApp — no PropertiesService,
|
|
6
|
+
// CacheService, ScriptApp, or per-instance config.
|
|
7
|
+
//
|
|
8
|
+
// Excluded (stay in src/api/ddl.ts for DB scripts):
|
|
9
|
+
// ddlCacheSchema, ddlGetCachedSchema, ddlInvalidateCache, ddlRefreshCache
|
|
10
|
+
// ddlSetApiKey, ddlGetApiKey, ddlRotateApiKey
|
|
11
|
+
// ddlSetupCacheTrigger, ddlRemoveCacheTrigger, ddlRefreshCacheTrigger
|
|
12
|
+
// ========================================
|
|
13
|
+
|
|
14
|
+
// ── Drive Folder Management ─────────────
|
|
15
|
+
|
|
16
|
+
function ddlCreateDriveFolder(params: { name: string; parentFolderId?: string }): any {
|
|
17
|
+
const folderName = params.name;
|
|
18
|
+
let folder: GoogleAppsScript.Drive.Folder;
|
|
19
|
+
|
|
20
|
+
if (params.parentFolderId) {
|
|
21
|
+
const parent = DriveApp.getFolderById(params.parentFolderId);
|
|
22
|
+
// Check if folder already exists
|
|
23
|
+
const existing = parent.getFoldersByName(folderName);
|
|
24
|
+
if (existing.hasNext()) {
|
|
25
|
+
folder = existing.next();
|
|
26
|
+
} else {
|
|
27
|
+
folder = parent.createFolder(folderName);
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
folder = DriveApp.createFolder(folderName);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
folderId: folder.getId(),
|
|
35
|
+
folderName: folder.getName(),
|
|
36
|
+
folderUrl: folder.getUrl()
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Setup: Create folder + system spreadsheet ──
|
|
41
|
+
|
|
42
|
+
function ddlSetup(params: { name: string; driveFolderId?: string }): any {
|
|
43
|
+
const logger = getLogger();
|
|
44
|
+
const ddl = getDDL();
|
|
45
|
+
const dbName = params.name;
|
|
46
|
+
|
|
47
|
+
// 1. Create or get Drive folder
|
|
48
|
+
let folderId = params.driveFolderId;
|
|
49
|
+
let folderResult: any = null;
|
|
50
|
+
|
|
51
|
+
if (!folderId) {
|
|
52
|
+
folderResult = ddlCreateDriveFolder({ name: dbName });
|
|
53
|
+
folderId = folderResult.folderId;
|
|
54
|
+
logger.info('ddlSetup', `Created Drive folder "${dbName}"`, { folderId });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const folder = DriveApp.getFolderById(folderId);
|
|
58
|
+
|
|
59
|
+
// 2. Create system spreadsheet inside the folder
|
|
60
|
+
const sysName = `${dbName}__system`;
|
|
61
|
+
const sysResult = ddl.createSpreadsheet(sysName);
|
|
62
|
+
const sysFile = DriveApp.getFileById(sysResult.spreadsheetId);
|
|
63
|
+
folder.addFile(sysFile);
|
|
64
|
+
// Remove from root if it was created there
|
|
65
|
+
const rootFolder = DriveApp.getRootFolder();
|
|
66
|
+
rootFolder.removeFile(sysFile);
|
|
67
|
+
|
|
68
|
+
logger.info('ddlSetup', `Created system spreadsheet "${sysName}"`, { spreadsheetId: sysResult.spreadsheetId });
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
folderId: folderId,
|
|
72
|
+
folderUrl: folderResult?.folderUrl || folder.getUrl(),
|
|
73
|
+
systemSpreadsheetId: sysResult.spreadsheetId,
|
|
74
|
+
systemSpreadsheetUrl: sysResult.url
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Data Spreadsheet Creation ───────────
|
|
79
|
+
|
|
80
|
+
function ddlCreateDataSpreadsheet(params: { name: string; folderId: string }): any {
|
|
81
|
+
const ddlName = params.name;
|
|
82
|
+
const ss = SpreadsheetApp.create(ddlName);
|
|
83
|
+
const spreadsheetId = ss.getId();
|
|
84
|
+
|
|
85
|
+
// Move into the Drive folder
|
|
86
|
+
const folder = DriveApp.getFolderById(params.folderId);
|
|
87
|
+
const file = DriveApp.getFileById(spreadsheetId);
|
|
88
|
+
folder.addFile(file);
|
|
89
|
+
DriveApp.getRootFolder().removeFile(file);
|
|
90
|
+
|
|
91
|
+
// Remove default Sheet1
|
|
92
|
+
const defaultSheet = ss.getSheetByName('Sheet1');
|
|
93
|
+
if (defaultSheet && ss.getSheets().length > 1) {
|
|
94
|
+
ss.deleteSheet(defaultSheet);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
spreadsheetId: spreadsheetId,
|
|
99
|
+
url: ss.getUrl(),
|
|
100
|
+
name: ddlName
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Legacy single-spreadsheet creation ──
|
|
105
|
+
|
|
106
|
+
function ddlCreateSpreadsheet(params: { name: string }): any {
|
|
107
|
+
return getDDL().createSpreadsheet(params.name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Table Provisioning ──────────────────
|
|
111
|
+
|
|
112
|
+
function ddlProvisionTables(params: { spreadsheetId: string; tables: Record<string, any> }): any {
|
|
113
|
+
return getDDL().provisionTables(params.spreadsheetId, params.tables);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Table Inspection ────────────────────
|
|
117
|
+
|
|
118
|
+
function ddlShowTables(params: { spreadsheetId: string }): any {
|
|
119
|
+
return getDDL().showTables(params.spreadsheetId);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function ddlDescribeTable(params: { spreadsheetId: string; table: string }): any {
|
|
123
|
+
return getDDL().describeTable(params.spreadsheetId, params.table);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Table Deletion ──────────────────────
|
|
127
|
+
|
|
128
|
+
function ddlDropTable(params: { spreadsheetId: string; table: string }): any {
|
|
129
|
+
return getDDL().dropTable(params.spreadsheetId, params.table);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Schema Sync ─────────────────────────
|
|
133
|
+
|
|
134
|
+
function ddlSyncSchema(params: { spreadsheetId: string; tables: Record<string, any> }): any {
|
|
135
|
+
return getDDL().syncSchema(params.spreadsheetId, params.tables);
|
|
136
|
+
}
|
package/src/api/ddl.ts
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// ========================================
|
|
2
|
+
// DDL Entry Points — thin wrappers for clasp run
|
|
3
|
+
//
|
|
4
|
+
// Architecture:
|
|
5
|
+
// - System spreadsheet: holds __sys__tables__ metadata (separate from data)
|
|
6
|
+
// - Data spreadsheets: hold actual tables (can be multiple per DB)
|
|
7
|
+
// - Drive folder: organizes all spreadsheets for a DB+env
|
|
8
|
+
// - CacheService: caches schema for fast access between refreshes
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// clasp run ddlSetup --params '[{"name":"my-db"}]'
|
|
12
|
+
// clasp run ddlProvisionTables --params '[{"systemSpreadsheetId":"...","tables":{...}}]'
|
|
13
|
+
// clasp run ddlShowTables --params '[{"systemSpreadsheetId":"..."}]'
|
|
14
|
+
// clasp run ddlDescribeTable --params '[{"systemSpreadsheetId":"...","table":"users"}]'
|
|
15
|
+
// clasp run ddlDropTable --params '[{"systemSpreadsheetId":"...","table":"users"}]'
|
|
16
|
+
// clasp run ddlSyncSchema --params '[{"systemSpreadsheetId":"...","tables":{...}}]'
|
|
17
|
+
// ========================================
|
|
18
|
+
|
|
19
|
+
const CACHE_TTL_SECONDS = 3600; // 1 hour
|
|
20
|
+
const CACHE_KEY_PREFIX = 'ddl_schema_';
|
|
21
|
+
|
|
22
|
+
function getDDL(): DBDDLV2 {
|
|
23
|
+
return new DBDDLV2(getLogger());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Drive Folder Management ─────────────
|
|
27
|
+
|
|
28
|
+
function ddlCreateDriveFolder(params: { name: string; parentFolderId?: string }): any {
|
|
29
|
+
const folderName = params.name;
|
|
30
|
+
let folder: GoogleAppsScript.Drive.Folder;
|
|
31
|
+
|
|
32
|
+
if (params.parentFolderId) {
|
|
33
|
+
const parent = DriveApp.getFolderById(params.parentFolderId);
|
|
34
|
+
// Check if folder already exists
|
|
35
|
+
const existing = parent.getFoldersByName(folderName);
|
|
36
|
+
if (existing.hasNext()) {
|
|
37
|
+
folder = existing.next();
|
|
38
|
+
} else {
|
|
39
|
+
folder = parent.createFolder(folderName);
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
folder = DriveApp.createFolder(folderName);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
folderId: folder.getId(),
|
|
47
|
+
folderName: folder.getName(),
|
|
48
|
+
folderUrl: folder.getUrl()
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Setup: Create folder + system spreadsheet ──
|
|
53
|
+
|
|
54
|
+
function ddlSetup(params: { name: string; driveFolderId?: string }): any {
|
|
55
|
+
const logger = getLogger();
|
|
56
|
+
const ddl = getDDL();
|
|
57
|
+
const dbName = params.name;
|
|
58
|
+
|
|
59
|
+
// 1. Create or get Drive folder
|
|
60
|
+
let folderId = params.driveFolderId;
|
|
61
|
+
let folderResult: any = null;
|
|
62
|
+
|
|
63
|
+
if (!folderId) {
|
|
64
|
+
folderResult = ddlCreateDriveFolder({ name: dbName });
|
|
65
|
+
folderId = folderResult.folderId;
|
|
66
|
+
logger.info('ddlSetup', `Created Drive folder "${dbName}"`, { folderId });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const folder = DriveApp.getFolderById(folderId);
|
|
70
|
+
|
|
71
|
+
// 2. Create system spreadsheet inside the folder
|
|
72
|
+
const sysName = `${dbName}__system`;
|
|
73
|
+
const sysResult = ddl.createSpreadsheet(sysName);
|
|
74
|
+
const sysFile = DriveApp.getFileById(sysResult.spreadsheetId);
|
|
75
|
+
folder.addFile(sysFile);
|
|
76
|
+
// Remove from root if it was created there
|
|
77
|
+
const rootFolder = DriveApp.getRootFolder();
|
|
78
|
+
rootFolder.removeFile(sysFile);
|
|
79
|
+
|
|
80
|
+
logger.info('ddlSetup', `Created system spreadsheet "${sysName}"`, { spreadsheetId: sysResult.spreadsheetId });
|
|
81
|
+
|
|
82
|
+
// 3. Set up cache refresh trigger
|
|
83
|
+
const triggerResult = ddlSetupCacheTrigger({ intervalHours: 1 });
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
folderId: folderId,
|
|
87
|
+
folderUrl: folderResult?.folderUrl || folder.getUrl(),
|
|
88
|
+
systemSpreadsheetId: sysResult.spreadsheetId,
|
|
89
|
+
systemSpreadsheetUrl: sysResult.url,
|
|
90
|
+
trigger: triggerResult
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Data Spreadsheet Creation ───────────
|
|
95
|
+
|
|
96
|
+
function ddlCreateDataSpreadsheet(params: { name: string; folderId: string }): any {
|
|
97
|
+
const ddlName = params.name;
|
|
98
|
+
const ss = SpreadsheetApp.create(ddlName);
|
|
99
|
+
const spreadsheetId = ss.getId();
|
|
100
|
+
|
|
101
|
+
// Move into the Drive folder
|
|
102
|
+
const folder = DriveApp.getFolderById(params.folderId);
|
|
103
|
+
const file = DriveApp.getFileById(spreadsheetId);
|
|
104
|
+
folder.addFile(file);
|
|
105
|
+
DriveApp.getRootFolder().removeFile(file);
|
|
106
|
+
|
|
107
|
+
// Remove default Sheet1
|
|
108
|
+
const defaultSheet = ss.getSheetByName('Sheet1');
|
|
109
|
+
if (defaultSheet && ss.getSheets().length > 1) {
|
|
110
|
+
ss.deleteSheet(defaultSheet);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
spreadsheetId: spreadsheetId,
|
|
115
|
+
url: ss.getUrl(),
|
|
116
|
+
name: ddlName
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── clasp run entry points (legacy — single spreadsheet) ──
|
|
121
|
+
|
|
122
|
+
function ddlCreateSpreadsheet(params: { name: string }): any {
|
|
123
|
+
return getDDL().createSpreadsheet(params.name);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ddlProvisionTables(params: { spreadsheetId: string; tables: Record<string, any> }): any {
|
|
127
|
+
const result = getDDL().provisionTables(params.spreadsheetId, params.tables);
|
|
128
|
+
// Cache the schema after provisioning
|
|
129
|
+
ddlCacheSchema({ tables: params.tables });
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ddlShowTables(params: { spreadsheetId: string }): any {
|
|
134
|
+
return getDDL().showTables(params.spreadsheetId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function ddlDescribeTable(params: { spreadsheetId: string; table: string }): any {
|
|
138
|
+
return getDDL().describeTable(params.spreadsheetId, params.table);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function ddlDropTable(params: { spreadsheetId: string; table: string }): any {
|
|
142
|
+
return getDDL().dropTable(params.spreadsheetId, params.table);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function ddlSyncSchema(params: { spreadsheetId: string; tables: Record<string, any> }): any {
|
|
146
|
+
const result = getDDL().syncSchema(params.spreadsheetId, params.tables);
|
|
147
|
+
// Update cache after sync
|
|
148
|
+
ddlCacheSchema({ tables: params.tables });
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Schema Caching (CacheService) ───────
|
|
153
|
+
|
|
154
|
+
function ddlCacheSchema(params: { tables: Record<string, any> }): any {
|
|
155
|
+
const cache = CacheService.getScriptCache();
|
|
156
|
+
const tableNames = Object.keys(params.tables);
|
|
157
|
+
|
|
158
|
+
// Cache each table's schema individually for granular access
|
|
159
|
+
for (const [tableName, tableConfig] of Object.entries(params.tables)) {
|
|
160
|
+
const cacheKey = CACHE_KEY_PREFIX + tableName;
|
|
161
|
+
cache.put(cacheKey, JSON.stringify(tableConfig), CACHE_TTL_SECONDS);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Cache the full table list
|
|
165
|
+
cache.put(CACHE_KEY_PREFIX + '__index__', JSON.stringify(tableNames), CACHE_TTL_SECONDS);
|
|
166
|
+
|
|
167
|
+
return { status: 'ok', cached: tableNames.length, ttlSeconds: CACHE_TTL_SECONDS };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function ddlGetCachedSchema(params?: { table?: string }): any {
|
|
171
|
+
const cache = CacheService.getScriptCache();
|
|
172
|
+
|
|
173
|
+
if (params?.table) {
|
|
174
|
+
const cached = cache.get(CACHE_KEY_PREFIX + params.table);
|
|
175
|
+
if (!cached) return { status: 'miss', table: params.table };
|
|
176
|
+
return { status: 'hit', table: params.table, schema: JSON.parse(cached) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Return full index
|
|
180
|
+
const index = cache.get(CACHE_KEY_PREFIX + '__index__');
|
|
181
|
+
if (!index) return { status: 'miss' };
|
|
182
|
+
|
|
183
|
+
const tableNames = JSON.parse(index);
|
|
184
|
+
const schemas: Record<string, any> = {};
|
|
185
|
+
for (const name of tableNames) {
|
|
186
|
+
const cached = cache.get(CACHE_KEY_PREFIX + name);
|
|
187
|
+
if (cached) schemas[name] = JSON.parse(cached);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { status: 'hit', tables: schemas };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function ddlInvalidateCache(): any {
|
|
194
|
+
const cache = CacheService.getScriptCache();
|
|
195
|
+
const index = cache.get(CACHE_KEY_PREFIX + '__index__');
|
|
196
|
+
|
|
197
|
+
if (index) {
|
|
198
|
+
const tableNames = JSON.parse(index);
|
|
199
|
+
for (const name of tableNames) {
|
|
200
|
+
cache.remove(CACHE_KEY_PREFIX + name);
|
|
201
|
+
}
|
|
202
|
+
cache.remove(CACHE_KEY_PREFIX + '__index__');
|
|
203
|
+
return { status: 'ok', invalidated: tableNames.length };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { status: 'ok', invalidated: 0 };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── API Key Management (stored in Script Properties) ──
|
|
210
|
+
|
|
211
|
+
function ddlSetApiKey(params: { key: string }): any {
|
|
212
|
+
if (!params.key) throw new Error('Missing "key" parameter');
|
|
213
|
+
PropertiesService.getScriptProperties().setProperty('DDL_API_KEY', params.key);
|
|
214
|
+
return { status: 'ok', message: 'DDL API key set' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function ddlGetApiKey(): any {
|
|
218
|
+
const key = PropertiesService.getScriptProperties().getProperty('DDL_API_KEY');
|
|
219
|
+
return { hasKey: !!key, keyPrefix: key ? key.substring(0, 4) + '...' : null };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function ddlRotateApiKey(): any {
|
|
223
|
+
const newKey = Utilities.getUuid().replace(/-/g, '');
|
|
224
|
+
PropertiesService.getScriptProperties().setProperty('DDL_API_KEY', newKey);
|
|
225
|
+
return { status: 'ok', key: newKey };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Cache Refresh (refreshes __sys__tables__ + CacheService) ──
|
|
229
|
+
|
|
230
|
+
function ddlRefreshCache(params?: { systemSpreadsheetId?: string }): any {
|
|
231
|
+
const ssId = params?.systemSpreadsheetId || DEPLOYER_CONFIG.spreadsheetId;
|
|
232
|
+
if (!ssId) throw new Error('No systemSpreadsheetId provided or configured');
|
|
233
|
+
|
|
234
|
+
const ss = SpreadsheetApp.openById(ssId);
|
|
235
|
+
|
|
236
|
+
// Rebuild __sys__tables__ from live sheets
|
|
237
|
+
const sysSheet = ss.getSheetByName('__sys__tables__');
|
|
238
|
+
if (!sysSheet) return { status: 'skipped', reason: 'no __sys__tables__ sheet' };
|
|
239
|
+
|
|
240
|
+
// Read existing sys table entries to get spreadsheet IDs
|
|
241
|
+
const data = sysSheet.getDataRange().getValues();
|
|
242
|
+
const spreadsheetIds = new Set<string>();
|
|
243
|
+
for (let i = 1; i < data.length; i++) {
|
|
244
|
+
if (data[i][2]) spreadsheetIds.add(String(data[i][2]));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Clear and rebuild from all known spreadsheets
|
|
248
|
+
const lastRow = sysSheet.getLastRow();
|
|
249
|
+
if (lastRow > 1) {
|
|
250
|
+
sysSheet.getRange(2, 1, lastRow - 1, sysSheet.getLastColumn()).clearContent();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let count = 0;
|
|
254
|
+
for (const sid of spreadsheetIds) {
|
|
255
|
+
try {
|
|
256
|
+
const dataSs = SpreadsheetApp.openById(sid);
|
|
257
|
+
for (const sheet of dataSs.getSheets()) {
|
|
258
|
+
const name = sheet.getName();
|
|
259
|
+
if (name.startsWith('__sys__')) continue;
|
|
260
|
+
const cols = sheet.getLastColumn();
|
|
261
|
+
sysSheet.appendRow([name, name, sid, new Date().toISOString(), cols]);
|
|
262
|
+
count++;
|
|
263
|
+
}
|
|
264
|
+
} catch (e) {
|
|
265
|
+
// Skip inaccessible spreadsheets
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Also refresh CacheService if DB_SCHEMA is available
|
|
270
|
+
if (typeof DB_SCHEMA !== 'undefined') {
|
|
271
|
+
ddlCacheSchema({ tables: DB_SCHEMA });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { status: 'ok', tablesRefreshed: count };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Trigger Management ──────────────────
|
|
278
|
+
|
|
279
|
+
function ddlSetupCacheTrigger(params: { intervalHours?: number }): any {
|
|
280
|
+
const hours = params?.intervalHours || 1;
|
|
281
|
+
|
|
282
|
+
// Remove existing trigger if any
|
|
283
|
+
const triggers = ScriptApp.getProjectTriggers();
|
|
284
|
+
for (const trigger of triggers) {
|
|
285
|
+
if (trigger.getHandlerFunction() === 'ddlRefreshCacheTrigger') {
|
|
286
|
+
ScriptApp.deleteTrigger(trigger);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Create new time-based trigger
|
|
291
|
+
ScriptApp.newTrigger('ddlRefreshCacheTrigger')
|
|
292
|
+
.timeBased()
|
|
293
|
+
.everyHours(hours)
|
|
294
|
+
.create();
|
|
295
|
+
|
|
296
|
+
return { status: 'ok', intervalHours: hours, message: `Trigger set to refresh cache every ${hours}h` };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function ddlRemoveCacheTrigger(): any {
|
|
300
|
+
const triggers = ScriptApp.getProjectTriggers();
|
|
301
|
+
let removed = 0;
|
|
302
|
+
for (const trigger of triggers) {
|
|
303
|
+
if (trigger.getHandlerFunction() === 'ddlRefreshCacheTrigger') {
|
|
304
|
+
ScriptApp.deleteTrigger(trigger);
|
|
305
|
+
removed++;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return { status: 'ok', removed };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Trigger handler — called by time-based trigger
|
|
312
|
+
function ddlRefreshCacheTrigger(): void {
|
|
313
|
+
const logger = getLogger();
|
|
314
|
+
try {
|
|
315
|
+
const result = ddlRefreshCache();
|
|
316
|
+
logger.info('ddlRefreshCacheTrigger', 'Cache refreshed', result);
|
|
317
|
+
} catch (err: any) {
|
|
318
|
+
logger.warn('ddlRefreshCacheTrigger', 'Cache refresh failed', { error: String(err) });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"scriptId": "<%- script_id %>", "rootDir": "."<% if (project_id) { %>, "projectId": "<%- project_id %>"<% } %>}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"timeZone": "America/New_York",
|
|
3
|
+
"dependencies": {},
|
|
4
|
+
"exceptionLogging": "STACKDRIVER",
|
|
5
|
+
"runtimeVersion": "V8",
|
|
6
|
+
"webapp": {
|
|
7
|
+
"executeAs": "USER_DEPLOYING",
|
|
8
|
+
"access": "ANYONE_ANONYMOUS"
|
|
9
|
+
},
|
|
10
|
+
"oauthScopes": [
|
|
11
|
+
"https://www.googleapis.com/auth/spreadsheets",
|
|
12
|
+
"https://www.googleapis.com/auth/drive",
|
|
13
|
+
"https://www.googleapis.com/auth/script.external_request",
|
|
14
|
+
"https://www.googleapis.com/auth/script.scriptapp"
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Auto-generated by nsa-sheets-db-builder — do not edit manually
|
|
2
|
+
const ENV = '<%- env %>';
|
|
3
|
+
const INSTANCE_TYPE = '<%- instance_type %>';
|
|
4
|
+
const LOGGING_VERBOSITY_LEVEL = <%- logging_verbosity %>;
|
|
5
|
+
|
|
6
|
+
const DEPLOYER_CONFIG = {
|
|
7
|
+
name: '<%- config_name %>',
|
|
8
|
+
env: ENV,
|
|
9
|
+
instanceType: INSTANCE_TYPE,
|
|
10
|
+
systemSpreadsheetId: '<%- system_spreadsheet_id %>',
|
|
11
|
+
driveFolderId: '<%- drive_folder_id %>',
|
|
12
|
+
loggingVerbosity: LOGGING_VERBOSITY_LEVEL,
|
|
13
|
+
ddlAdminKey: '<%- ddl_admin_key %>'
|
|
14
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ========================================
|
|
2
|
+
// DDL Handler — Entry Point (POST-only)
|
|
3
|
+
// Auto-generated by nsa-sheets-db-builder
|
|
4
|
+
//
|
|
5
|
+
// Generic, schema-agnostic DDL handler.
|
|
6
|
+
// Deployed once per Google account, shared across all databases.
|
|
7
|
+
// Receives schema/config as parameters (not baked in at build time).
|
|
8
|
+
// ========================================
|
|
9
|
+
|
|
10
|
+
function getLogger(): any {
|
|
11
|
+
if (typeof GASLoggerV2 !== 'undefined') {
|
|
12
|
+
return new GASLoggerV2('ddl-handler', null, LOGGING_VERBOSITY_LEVEL, '');
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
info: (src: string, msg: string, ctx?: any) => console.log(`[INFO] ${src}: ${msg}`),
|
|
16
|
+
warn: (src: string, msg: string, ctx?: any) => console.warn(`[WARN] ${src}: ${msg}`),
|
|
17
|
+
error: (err: any) => console.error(`[ERROR]`, err?.message || err),
|
|
18
|
+
debug: (src: string, msg: string, ctx?: any) => {},
|
|
19
|
+
getTraceId: () => ''
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getDDL(): DBDDLV2 {
|
|
24
|
+
return new DBDDLV2(getLogger());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function jsonResponse(data: any): GoogleAppsScript.Content.TextOutput {
|
|
28
|
+
return ContentService
|
|
29
|
+
.createTextOutput(JSON.stringify(data))
|
|
30
|
+
.setMimeType(ContentService.MimeType.JSON);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function doPost(e: GoogleAppsScript.Events.DoPost): GoogleAppsScript.Content.TextOutput {
|
|
34
|
+
try {
|
|
35
|
+
const body = JSON.parse(e?.postData?.contents || '{}');
|
|
36
|
+
|
|
37
|
+
// Auth: validate DDL admin key
|
|
38
|
+
if (!DDL_ADMIN_KEY || body.ddlKey !== DDL_ADMIN_KEY) {
|
|
39
|
+
return jsonResponse({ error: 'Unauthorized: invalid DDL admin key' });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fnName = body.ddlAction;
|
|
43
|
+
if (typeof fnName !== 'string' || !fnName.startsWith('ddl')) {
|
|
44
|
+
return jsonResponse({ error: 'Invalid DDL action' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const fn = (globalThis as any)[fnName];
|
|
48
|
+
if (typeof fn !== 'function') {
|
|
49
|
+
return jsonResponse({ error: 'Unknown DDL function: ' + fnName });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return jsonResponse(fn(body.params));
|
|
53
|
+
} catch (err: any) {
|
|
54
|
+
return jsonResponse({ error: err.message || String(err) });
|
|
55
|
+
}
|
|
56
|
+
}
|