@rebasepro/server-postgresql 0.0.1-canary.09e5ec5
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 +6 -0
- package/README.md +106 -0
- package/build-errors.txt +37 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +58 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +22 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index.es.js +11298 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +11306 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
- package/dist/server-postgresql/src/auth/services.d.ts +192 -0
- package/dist/server-postgresql/src/cli.d.ts +1 -0
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
- package/dist/server-postgresql/src/connection.d.ts +40 -0
- package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
- package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
- package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
- package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
- package/dist/server-postgresql/src/index.d.ts +13 -0
- package/dist/server-postgresql/src/interfaces.d.ts +18 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
- package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
- package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
- package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
- package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
- package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
- package/dist/server-postgresql/src/services/index.d.ts +4 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
- package/dist/server-postgresql/src/websocket.d.ts +5 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +119 -0
- package/dist/types/src/controllers/client.d.ts +170 -0
- package/dist/types/src/controllers/collection_registry.d.ts +45 -0
- package/dist/types/src/controllers/customization_controller.d.ts +60 -0
- package/dist/types/src/controllers/data.d.ts +168 -0
- package/dist/types/src/controllers/data_driver.d.ts +160 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +18 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +54 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +171 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +105 -0
- package/dist/types/src/types/backend.d.ts +536 -0
- package/dist/types/src/types/builders.d.ts +15 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +856 -0
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +10 -0
- package/dist/types/src/types/entity_views.d.ts +61 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/index.d.ts +23 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +279 -0
- package/dist/types/src/types/properties.d.ts +1176 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +252 -0
- package/dist/types/src/types/translations.d.ts +870 -0
- package/dist/types/src/types/user_management_delegate.d.ts +121 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -0
- package/drizzle-test/0000_woozy_junta.sql +6 -0
- package/drizzle-test/0001_youthful_arachne.sql +1 -0
- package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
- package/drizzle-test/0003_mean_king_cobra.sql +2 -0
- package/drizzle-test/meta/0000_snapshot.json +47 -0
- package/drizzle-test/meta/0001_snapshot.json +48 -0
- package/drizzle-test/meta/0002_snapshot.json +38 -0
- package/drizzle-test/meta/0003_snapshot.json +48 -0
- package/drizzle-test/meta/_journal.json +34 -0
- package/drizzle-test-out/0000_tan_trauma.sql +6 -0
- package/drizzle-test-out/0001_rapid_drax.sql +1 -0
- package/drizzle-test-out/meta/0000_snapshot.json +44 -0
- package/drizzle-test-out/meta/0001_snapshot.json +54 -0
- package/drizzle-test-out/meta/_journal.json +20 -0
- package/drizzle.test.config.ts +10 -0
- package/jest-all.log +3128 -0
- package/jest.log +49 -0
- package/package.json +92 -0
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +1008 -0
- package/src/PostgresBootstrapper.ts +231 -0
- package/src/auth/ensure-tables.ts +381 -0
- package/src/auth/services.ts +799 -0
- package/src/cli.ts +648 -0
- package/src/collections/PostgresCollectionRegistry.ts +96 -0
- package/src/connection.ts +84 -0
- package/src/data-transformer.ts +608 -0
- package/src/databasePoolManager.ts +85 -0
- package/src/history/HistoryService.ts +248 -0
- package/src/history/ensure-history-table.ts +45 -0
- package/src/index.ts +13 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-schema.ts +169 -0
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +765 -0
- package/src/schema/generate-drizzle-schema.ts +151 -0
- package/src/schema/introspect-db-logic.ts +542 -0
- package/src/schema/introspect-db.ts +211 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +237 -0
- package/src/services/EntityFetchService.ts +1576 -0
- package/src/services/EntityPersistService.ts +349 -0
- package/src/services/RelationService.ts +1274 -0
- package/src/services/entity-helpers.ts +147 -0
- package/src/services/entityService.ts +211 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1034 -0
- package/src/utils/drizzle-conditions.ts +1000 -0
- package/src/websocket.ts +518 -0
- package/test/auth-services.test.ts +661 -0
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +367 -0
- package/test/data-transformer-hardening.test.ts +417 -0
- package/test/data-transformer.test.ts +175 -0
- package/test/doctor.test.ts +182 -0
- package/test/drizzle-conditions.test.ts +895 -0
- package/test/entityService.errors.test.ts +367 -0
- package/test/entityService.relations.test.ts +1008 -0
- package/test/entityService.subcollection-search.test.ts +566 -0
- package/test/entityService.test.ts +1035 -0
- package/test/generate-drizzle-schema.test.ts +988 -0
- package/test/historyService.test.ts +141 -0
- package/test/introspect-db-generation.test.ts +436 -0
- package/test/introspect-db-utils.test.ts +389 -0
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +648 -0
- package/test/realtimeService.test.ts +307 -0
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +1115 -0
- package/test/unmapped-tables-safety.test.ts +345 -0
- package/test-drizzle-bug.ts +18 -0
- package/test-drizzle-out/0000_cultured_freak.sql +7 -0
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
- package/test-drizzle-out/meta/0000_snapshot.json +55 -0
- package/test-drizzle-out/meta/0001_snapshot.json +63 -0
- package/test-drizzle-out/meta/_journal.json +20 -0
- package/test-drizzle-prompt.sh +2 -0
- package/test-policy-prompt.sh +3 -0
- package/test-programmatic.ts +30 -0
- package/test-programmatic2.ts +59 -0
- package/test-schema-no-policies.ts +12 -0
- package/test_drizzle_mock.js +3 -0
- package/test_find_changed.mjs +32 -0
- package/test_hash.js +14 -0
- package/test_output.txt +3145 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- package/vite.config.ts +82 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import arg from "arg";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import execa from "execa";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
function resolveLocalBin(binName: string): string | null {
|
|
12
|
+
let cwd = process.cwd();
|
|
13
|
+
// Try to find node_modules/.bin upwards
|
|
14
|
+
while (cwd !== "/") {
|
|
15
|
+
const candidate = path.join(cwd, "node_modules", ".bin", binName);
|
|
16
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
17
|
+
cwd = path.dirname(cwd);
|
|
18
|
+
}
|
|
19
|
+
// Fall back to globally installed binary via which
|
|
20
|
+
try {
|
|
21
|
+
const globalPath = execSync(`which ${binName}`, { encoding: "utf-8" }).trim();
|
|
22
|
+
if (globalPath && fs.existsSync(globalPath)) return globalPath;
|
|
23
|
+
} catch {
|
|
24
|
+
// not found globally
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runPluginCommand(args: string[]) {
|
|
30
|
+
const domain = args[0]; // "db" or "schema"
|
|
31
|
+
const subcommand = args[1];
|
|
32
|
+
|
|
33
|
+
if (domain === "db") {
|
|
34
|
+
await dbCommand(subcommand, args);
|
|
35
|
+
} else if (domain === "schema") {
|
|
36
|
+
await schemaCommand(subcommand, args);
|
|
37
|
+
} else if (domain === "doctor") {
|
|
38
|
+
await doctorPluginCommand(args);
|
|
39
|
+
} else {
|
|
40
|
+
console.error(chalk.red(`Unknown domain command: ${domain}`));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
46
|
+
const VALID_ACTIONS = ["push", "pull", "generate", "migrate", "studio", "branch"];
|
|
47
|
+
if (!subcommand || !VALID_ACTIONS.includes(subcommand)) {
|
|
48
|
+
console.error(chalk.red(`Unknown db command. Valid: ${VALID_ACTIONS.join(", ")}`));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (subcommand === "branch") {
|
|
53
|
+
await branchCommand(rawArgs);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (subcommand === "generate") {
|
|
58
|
+
console.log("");
|
|
59
|
+
console.log(chalk.bold(" 📦 Rebase DB Generate"));
|
|
60
|
+
console.log(chalk.gray(" Step 1/2: Generating Drizzle schema from collections..."));
|
|
61
|
+
console.log("");
|
|
62
|
+
await schemaCommand("generate", rawArgs);
|
|
63
|
+
console.log("");
|
|
64
|
+
console.log(chalk.gray(" Step 2/2: Generating SQL migration files..."));
|
|
65
|
+
console.log("");
|
|
66
|
+
await runDrizzleKit("generate", rawArgs);
|
|
67
|
+
await fixMigrationStatementOrder();
|
|
68
|
+
console.log("");
|
|
69
|
+
console.log(` You can now run ${chalk.bold.green("rebase db migrate")} to apply the migrations to your database.`);
|
|
70
|
+
console.log("");
|
|
71
|
+
} else {
|
|
72
|
+
console.log("");
|
|
73
|
+
console.log(chalk.bold(` 🗄️ Rebase DB ${subcommand.charAt(0).toUpperCase() + subcommand.slice(1)}`));
|
|
74
|
+
console.log("");
|
|
75
|
+
|
|
76
|
+
if (subcommand === "push") {
|
|
77
|
+
console.log(chalk.gray(" Step 1/2: Generating Drizzle schema from collections..."));
|
|
78
|
+
console.log("");
|
|
79
|
+
await schemaCommand("generate", rawArgs);
|
|
80
|
+
console.log("");
|
|
81
|
+
console.log(chalk.gray(" Step 2/2: Pushing schema to database..."));
|
|
82
|
+
console.log("");
|
|
83
|
+
await runDrizzleKit("push", rawArgs);
|
|
84
|
+
} else if (subcommand === "migrate") {
|
|
85
|
+
await runDrizzleKit("migrate", rawArgs);
|
|
86
|
+
} else {
|
|
87
|
+
await runDrizzleKit(subcommand, rawArgs);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log("");
|
|
91
|
+
console.log(chalk.green(` ✓ rebase db ${subcommand} completed successfully.`));
|
|
92
|
+
console.log("");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function branchCommand(rawArgs: string[]): Promise<void> {
|
|
97
|
+
const branchAction = rawArgs[2]; // create, list, delete, info
|
|
98
|
+
|
|
99
|
+
if (!branchAction || branchAction === "--help") {
|
|
100
|
+
printBranchHelp();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Load .env for DATABASE_URL
|
|
105
|
+
try {
|
|
106
|
+
const dotenv = await import("dotenv");
|
|
107
|
+
const envPath = process.env.DOTENV_CONFIG_PATH;
|
|
108
|
+
if (envPath) {
|
|
109
|
+
dotenv.config({ path: envPath });
|
|
110
|
+
} else {
|
|
111
|
+
dotenv.config();
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// dotenv may not be installed
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
|
|
118
|
+
if (!databaseUrl) {
|
|
119
|
+
console.error(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Dynamic imports to avoid loading heavy deps when not needed
|
|
124
|
+
const { DatabasePoolManager } = await import("./databasePoolManager");
|
|
125
|
+
const { BranchService } = await import("./services/BranchService");
|
|
126
|
+
const { drizzle } = await import("drizzle-orm/node-postgres");
|
|
127
|
+
const { Pool } = await import("pg");
|
|
128
|
+
|
|
129
|
+
const pool = new Pool({ connectionString: databaseUrl,
|
|
130
|
+
max: 3 });
|
|
131
|
+
const db = drizzle(pool);
|
|
132
|
+
const poolManager = new DatabasePoolManager(databaseUrl);
|
|
133
|
+
const branchService = new BranchService(db, poolManager);
|
|
134
|
+
|
|
135
|
+
// Ensure metadata table exists
|
|
136
|
+
await branchService.ensureBranchMetadataTable();
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
switch (branchAction) {
|
|
140
|
+
case "create": {
|
|
141
|
+
const name = rawArgs[3];
|
|
142
|
+
if (!name) {
|
|
143
|
+
console.error(chalk.red("✗ Branch name is required."));
|
|
144
|
+
console.log(chalk.gray(" Usage: rebase db branch create <name> [--from <source>]"));
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
let source: string | undefined;
|
|
148
|
+
const fromIdx = rawArgs.indexOf("--from");
|
|
149
|
+
if (fromIdx !== -1 && rawArgs[fromIdx + 1]) {
|
|
150
|
+
source = rawArgs[fromIdx + 1];
|
|
151
|
+
}
|
|
152
|
+
console.log("");
|
|
153
|
+
console.log(chalk.bold(" 🌿 Creating database branch..."));
|
|
154
|
+
console.log(chalk.gray(` Name: ${name}`));
|
|
155
|
+
if (source) console.log(chalk.gray(` Source: ${source}`));
|
|
156
|
+
console.log("");
|
|
157
|
+
const branch = await branchService.createBranch(name, source ? { source } : undefined);
|
|
158
|
+
console.log(chalk.green(` ✓ Branch "${branch.name}" created successfully.`));
|
|
159
|
+
console.log(chalk.gray(` Database: rb_${branch.name}`));
|
|
160
|
+
console.log(chalk.gray(` Parent: ${branch.parentDatabase}`));
|
|
161
|
+
console.log("");
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case "list": {
|
|
166
|
+
const branches = await branchService.listBranches();
|
|
167
|
+
console.log("");
|
|
168
|
+
if (branches.length === 0) {
|
|
169
|
+
console.log(chalk.gray(" No branches found. Create one with: rebase db branch create <name>"));
|
|
170
|
+
} else {
|
|
171
|
+
console.log(chalk.bold(` 🌿 ${branches.length} branch(es):`));
|
|
172
|
+
console.log("");
|
|
173
|
+
for (const b of branches) {
|
|
174
|
+
const size = b.sizeBytes != null
|
|
175
|
+
? chalk.gray(` (${formatBytes(b.sizeBytes)})`)
|
|
176
|
+
: "";
|
|
177
|
+
const age = chalk.gray(` — created ${timeAgo(b.createdAt)}`);
|
|
178
|
+
console.log(` ${chalk.green("●")} ${chalk.bold(b.name)}${size}${age}`);
|
|
179
|
+
console.log(chalk.gray(` from ${b.parentDatabase}`));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
console.log("");
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case "delete": {
|
|
187
|
+
const name = rawArgs[3];
|
|
188
|
+
if (!name) {
|
|
189
|
+
console.error(chalk.red("✗ Branch name is required."));
|
|
190
|
+
console.log(chalk.gray(" Usage: rebase db branch delete <name>"));
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
console.log("");
|
|
194
|
+
console.log(chalk.bold(` 🗑️ Deleting branch "${name}"...`));
|
|
195
|
+
await branchService.deleteBranch(name);
|
|
196
|
+
console.log(chalk.green(` ✓ Branch "${name}" deleted.`));
|
|
197
|
+
console.log("");
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case "info": {
|
|
202
|
+
const name = rawArgs[3];
|
|
203
|
+
if (!name) {
|
|
204
|
+
console.error(chalk.red("✗ Branch name is required."));
|
|
205
|
+
console.log(chalk.gray(" Usage: rebase db branch info <name>"));
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
const info = await branchService.getBranchInfo(name);
|
|
209
|
+
console.log("");
|
|
210
|
+
if (!info) {
|
|
211
|
+
console.error(chalk.red(` ✗ Branch "${name}" not found.`));
|
|
212
|
+
} else {
|
|
213
|
+
console.log(chalk.bold(` 🌿 Branch: ${info.name}`));
|
|
214
|
+
console.log(chalk.gray(` Database: rb_${info.name}`));
|
|
215
|
+
console.log(chalk.gray(` Parent: ${info.parentDatabase}`));
|
|
216
|
+
console.log(chalk.gray(` Created: ${info.createdAt.toISOString()}`));
|
|
217
|
+
if (info.sizeBytes != null) {
|
|
218
|
+
console.log(chalk.gray(` Size: ${formatBytes(info.sizeBytes)}`));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
console.log("");
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
default:
|
|
226
|
+
console.error(chalk.red(`Unknown branch action: "${branchAction}".`));
|
|
227
|
+
printBranchHelp();
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
} finally {
|
|
231
|
+
await poolManager.shutdown();
|
|
232
|
+
await pool.end();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function printBranchHelp() {
|
|
237
|
+
console.log(`
|
|
238
|
+
${chalk.bold("rebase db branch")} — Database branching commands
|
|
239
|
+
|
|
240
|
+
${chalk.green.bold("Usage")}
|
|
241
|
+
rebase db branch ${chalk.blue("<command>")} [options]
|
|
242
|
+
|
|
243
|
+
${chalk.green.bold("Commands")}
|
|
244
|
+
${chalk.blue.bold("create")} <name> [--from <source>] Create a new branch
|
|
245
|
+
${chalk.blue.bold("list")} List all branches
|
|
246
|
+
${chalk.blue.bold("delete")} <name> Delete a branch
|
|
247
|
+
${chalk.blue.bold("info")} <name> Show branch details
|
|
248
|
+
|
|
249
|
+
${chalk.green.bold("Examples")}
|
|
250
|
+
${chalk.gray("# Create a branch from the current database")}
|
|
251
|
+
rebase db branch create feature_auth
|
|
252
|
+
|
|
253
|
+
${chalk.gray("# Create a branch from a specific source")}
|
|
254
|
+
rebase db branch create staging --from production
|
|
255
|
+
|
|
256
|
+
${chalk.gray("# List all branches")}
|
|
257
|
+
rebase db branch list
|
|
258
|
+
|
|
259
|
+
${chalk.gray("# Delete a branch")}
|
|
260
|
+
rebase db branch delete feature_auth
|
|
261
|
+
`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function formatBytes(bytes: number): string {
|
|
265
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
266
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
267
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
268
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function timeAgo(date: Date): string {
|
|
272
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
273
|
+
if (seconds < 60) return "just now";
|
|
274
|
+
const minutes = Math.floor(seconds / 60);
|
|
275
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
276
|
+
const hours = Math.floor(minutes / 60);
|
|
277
|
+
if (hours < 24) return `${hours}h ago`;
|
|
278
|
+
const days = Math.floor(hours / 24);
|
|
279
|
+
return `${days}d ago`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Post-process generated migration files to fix statement ordering issues.
|
|
284
|
+
*
|
|
285
|
+
* Drizzle-kit can emit DROP POLICY statements *after* ALTER TABLE ... ALTER COLUMN
|
|
286
|
+
* for the same table. Postgres rejects this with:
|
|
287
|
+
* "cannot alter type of a column used in a policy definition"
|
|
288
|
+
*
|
|
289
|
+
* This scans the drizzle output directory for the most recently modified .sql file
|
|
290
|
+
* and reorders statements so that DROP POLICY on a table always precedes any
|
|
291
|
+
* ALTER TABLE on that same table.
|
|
292
|
+
*/
|
|
293
|
+
async function fixMigrationStatementOrder(): Promise<void> {
|
|
294
|
+
const drizzleDir = path.join(process.cwd(), "drizzle");
|
|
295
|
+
if (!fs.existsSync(drizzleDir)) return;
|
|
296
|
+
|
|
297
|
+
// Find the most recently modified .sql file
|
|
298
|
+
const sqlFiles = fs.readdirSync(drizzleDir)
|
|
299
|
+
.filter(f => f.endsWith(".sql"))
|
|
300
|
+
.map(f => ({
|
|
301
|
+
name: f,
|
|
302
|
+
mtime: fs.statSync(path.join(drizzleDir, f)).mtimeMs
|
|
303
|
+
}))
|
|
304
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
305
|
+
|
|
306
|
+
if (sqlFiles.length === 0) return;
|
|
307
|
+
|
|
308
|
+
const latestFile = path.join(drizzleDir, sqlFiles[0].name);
|
|
309
|
+
const content = fs.readFileSync(latestFile, "utf-8");
|
|
310
|
+
const DELIMITER = "--> statement-breakpoint";
|
|
311
|
+
const parts = content.split(DELIMITER);
|
|
312
|
+
|
|
313
|
+
// Parse each statement to detect DROP POLICY and ALTER TABLE targets
|
|
314
|
+
const dropPolicyRe = /DROP\s+POLICY\s+.+?\s+ON\s+"([^"]+)"/i;
|
|
315
|
+
const alterTableRe = /ALTER\s+TABLE\s+"([^"]+)"\s+ALTER\s+COLUMN/i;
|
|
316
|
+
|
|
317
|
+
// Collect indices of DROP POLICY statements and what tables they target
|
|
318
|
+
const dropPolicyIndices = new Map<string, number[]>(); // table -> indices
|
|
319
|
+
const alterColumnIndices = new Map<string, number>(); // table -> first ALTER index
|
|
320
|
+
|
|
321
|
+
for (let i = 0; i < parts.length; i++) {
|
|
322
|
+
const stmt = parts[i].trim();
|
|
323
|
+
const dropMatch = stmt.match(dropPolicyRe);
|
|
324
|
+
if (dropMatch) {
|
|
325
|
+
const table = dropMatch[1];
|
|
326
|
+
if (!dropPolicyIndices.has(table)) dropPolicyIndices.set(table, []);
|
|
327
|
+
dropPolicyIndices.get(table)!.push(i);
|
|
328
|
+
}
|
|
329
|
+
const alterMatch = stmt.match(alterTableRe);
|
|
330
|
+
if (alterMatch) {
|
|
331
|
+
const table = alterMatch[1];
|
|
332
|
+
if (!alterColumnIndices.has(table)) alterColumnIndices.set(table, i);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Check if any DROP POLICY comes after an ALTER COLUMN on the same table
|
|
337
|
+
let needsReorder = false;
|
|
338
|
+
for (const [table, dropIndices] of dropPolicyIndices) {
|
|
339
|
+
const firstAlter = alterColumnIndices.get(table);
|
|
340
|
+
if (firstAlter !== undefined) {
|
|
341
|
+
for (const dropIdx of dropIndices) {
|
|
342
|
+
if (dropIdx > firstAlter) {
|
|
343
|
+
needsReorder = true;
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (needsReorder) break;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!needsReorder) return;
|
|
352
|
+
|
|
353
|
+
// Reorder: move DROP POLICY statements for affected tables before their ALTER TABLE
|
|
354
|
+
// Strategy: stable sort — DROP POLICY on table X gets priority over ALTER on table X
|
|
355
|
+
const stmtEntries = parts.map((stmt, idx) => ({ stmt,
|
|
356
|
+
idx }));
|
|
357
|
+
|
|
358
|
+
stmtEntries.sort((a, b) => {
|
|
359
|
+
const aDropMatch = a.stmt.trim().match(dropPolicyRe);
|
|
360
|
+
const bAlterMatch = b.stmt.trim().match(alterTableRe);
|
|
361
|
+
const bDropMatch = b.stmt.trim().match(dropPolicyRe);
|
|
362
|
+
const aAlterMatch = a.stmt.trim().match(alterTableRe);
|
|
363
|
+
|
|
364
|
+
// If a is DROP POLICY on table X and b is ALTER on table X, a goes first
|
|
365
|
+
if (aDropMatch && bAlterMatch && aDropMatch[1] === bAlterMatch[1]) return -1;
|
|
366
|
+
// If b is DROP POLICY on table X and a is ALTER on table X, b goes first
|
|
367
|
+
if (bDropMatch && aAlterMatch && bDropMatch[1] === aAlterMatch[1]) return 1;
|
|
368
|
+
// Otherwise preserve original order
|
|
369
|
+
return a.idx - b.idx;
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const reordered = stmtEntries.map(e => e.stmt).join(DELIMITER);
|
|
373
|
+
fs.writeFileSync(latestFile, reordered, "utf-8");
|
|
374
|
+
|
|
375
|
+
console.log(chalk.yellow(` ⚠ Reordered migration statements in ${sqlFiles[0].name} (DROP POLICY before ALTER COLUMN)`));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function runDrizzleKit(action: string, _rawArgs: string[]): Promise<void> {
|
|
379
|
+
const drizzleKitBin = resolveLocalBin("drizzle-kit");
|
|
380
|
+
if (!drizzleKitBin) {
|
|
381
|
+
console.error(chalk.red("✗ Could not find drizzle-kit binary."));
|
|
382
|
+
console.error(chalk.gray(" Install it with: pnpm add -D drizzle-kit"));
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const env = { ...process.env as Record<string, string> };
|
|
387
|
+
try {
|
|
388
|
+
const dotenv = await import("dotenv");
|
|
389
|
+
const envPaths = [
|
|
390
|
+
process.env.DOTENV_CONFIG_PATH,
|
|
391
|
+
path.resolve(process.cwd(), ".env"),
|
|
392
|
+
path.resolve(process.cwd(), "../.env"),
|
|
393
|
+
path.resolve(process.cwd(), "../../.env")
|
|
394
|
+
].filter(Boolean) as string[];
|
|
395
|
+
|
|
396
|
+
for (const p of envPaths) {
|
|
397
|
+
if (fs.existsSync(p)) {
|
|
398
|
+
const parsed = dotenv.config({ path: p });
|
|
399
|
+
if (parsed.parsed) {
|
|
400
|
+
Object.assign(env, parsed.parsed);
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
// dotenv may not be available — fall through
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const interactive = ["generate", "push"].includes(action);
|
|
410
|
+
|
|
411
|
+
// For push: always use --strict (prompts before destructive ops) and --verbose
|
|
412
|
+
// (shows all SQL). This ensures unmapped tables are never silently dropped.
|
|
413
|
+
const drizzleKitArgs = [action];
|
|
414
|
+
if (action === "push") {
|
|
415
|
+
drizzleKitArgs.push("--strict", "--verbose");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
if (interactive) {
|
|
420
|
+
await execa(drizzleKitBin, drizzleKitArgs, {
|
|
421
|
+
cwd: process.cwd(),
|
|
422
|
+
stdio: "inherit",
|
|
423
|
+
env
|
|
424
|
+
});
|
|
425
|
+
} else {
|
|
426
|
+
const child = execa(drizzleKitBin, [action], {
|
|
427
|
+
cwd: process.cwd(),
|
|
428
|
+
env,
|
|
429
|
+
reject: false
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Natively stream output while still capturing it for error parsing
|
|
433
|
+
child.stdout?.pipe(process.stdout);
|
|
434
|
+
child.stderr?.pipe(process.stderr);
|
|
435
|
+
|
|
436
|
+
const result = await child;
|
|
437
|
+
|
|
438
|
+
// eslint-disable-next-line no-control-regex
|
|
439
|
+
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\[?[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣷⣯⣟⡿⢿⣻⣽]+\]\s*/g, "");
|
|
440
|
+
const stdout = stripAnsi(result.stdout || "").trim();
|
|
441
|
+
const stderr = stripAnsi(result.stderr || "").trim();
|
|
442
|
+
|
|
443
|
+
if (result.exitCode !== 0) {
|
|
444
|
+
console.error(chalk.red(`\n✗ drizzle-kit ${action} failed.\n`));
|
|
445
|
+
const errorOutput = stderr || stdout;
|
|
446
|
+
if (errorOutput) {
|
|
447
|
+
const lines = errorOutput.split("\n").filter((l: string) => l.trim());
|
|
448
|
+
for (const line of lines) {
|
|
449
|
+
if (line.toLowerCase().includes("error") || line.includes("cannot") || line.includes("already exists") || line.includes("does not exist") || line.includes("violates") || line.includes("permission denied")) {
|
|
450
|
+
console.error(chalk.red(` ${line.trim()}`));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
console.error("");
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
} catch (err: unknown) {
|
|
459
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
460
|
+
// eslint-disable-next-line no-control-regex
|
|
461
|
+
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\[?[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⣷⣯⣟⡿⢿⣻⣽]+\]\s*/g, "");
|
|
462
|
+
const cleaned = stripAnsi(msg).trim();
|
|
463
|
+
console.error(chalk.red(`\n✗ drizzle-kit ${action} failed.\n`));
|
|
464
|
+
const lines = cleaned.split("\n").filter((l: string) => l.trim());
|
|
465
|
+
for (const line of lines) {
|
|
466
|
+
if (line.toLowerCase().includes("error") || line.includes("cannot") || line.includes("already exists") || line.includes("does not exist") || line.includes("violates")) {
|
|
467
|
+
console.error(chalk.red(` ${line.trim()}`));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (lines.length === 0) {
|
|
471
|
+
console.error(chalk.gray(` ${cleaned}`));
|
|
472
|
+
}
|
|
473
|
+
console.error("");
|
|
474
|
+
process.exit(1);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
479
|
+
if (subcommand === "generate") {
|
|
480
|
+
const argsList = arg(
|
|
481
|
+
{
|
|
482
|
+
"--collections": String,
|
|
483
|
+
"--output": String,
|
|
484
|
+
"--watch": Boolean,
|
|
485
|
+
"-c": "--collections",
|
|
486
|
+
"-o": "--output",
|
|
487
|
+
"-w": "--watch"
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
argv: rawArgs.slice(2), // db generate ... or schema generate ...
|
|
491
|
+
permissive: true
|
|
492
|
+
}
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
// Here we just invoke the local generate-drizzle-schema.ts since we are inside the postgresql-backend
|
|
496
|
+
// If installed in node_modules, __dirname is node_modules/@rebasepro/server-postgresql/dist or src.
|
|
497
|
+
const generatorScript = path.join(__dirname, "schema", "generate-drizzle-schema.ts");
|
|
498
|
+
if (!fs.existsSync(generatorScript)) {
|
|
499
|
+
console.error(chalk.red(`✗ Could not find generate-drizzle-schema.ts at ${generatorScript}`));
|
|
500
|
+
process.exit(1);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const tsxBin = resolveLocalBin("tsx");
|
|
504
|
+
if (!tsxBin) {
|
|
505
|
+
console.error(chalk.red("✗ Could not find tsx binary."));
|
|
506
|
+
process.exit(1);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const collectionsPath = argsList["--collections"] || path.join("..", "shared", "collections");
|
|
510
|
+
const outputPath = argsList["--output"] || path.join("src", "schema.generated.ts");
|
|
511
|
+
const watch = argsList["--watch"] || false;
|
|
512
|
+
|
|
513
|
+
console.log("");
|
|
514
|
+
console.log(chalk.bold(" 🔧 Rebase Schema Generator"));
|
|
515
|
+
console.log("");
|
|
516
|
+
|
|
517
|
+
const cmdParts = [
|
|
518
|
+
tsxBin,
|
|
519
|
+
generatorScript,
|
|
520
|
+
`--collections=${collectionsPath}`,
|
|
521
|
+
`--output=${outputPath}`
|
|
522
|
+
];
|
|
523
|
+
if (watch) {
|
|
524
|
+
cmdParts.push("--watch");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
await execa(cmdParts[0], cmdParts.slice(1), {
|
|
529
|
+
cwd: process.cwd(),
|
|
530
|
+
stdio: "inherit",
|
|
531
|
+
env: { ...process.env as Record<string, string> }
|
|
532
|
+
});
|
|
533
|
+
} catch (err: unknown) {
|
|
534
|
+
console.error(chalk.red(`✗ Failed to run schema generator: ${err instanceof Error ? err.message : String(err)}`));
|
|
535
|
+
process.exit(1);
|
|
536
|
+
}
|
|
537
|
+
} else if (subcommand === "introspect") {
|
|
538
|
+
const argsList = arg(
|
|
539
|
+
{
|
|
540
|
+
"--output": String,
|
|
541
|
+
"--force": Boolean,
|
|
542
|
+
"--schema": String,
|
|
543
|
+
"-o": "--output",
|
|
544
|
+
"-f": "--force"
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
argv: rawArgs.slice(2),
|
|
548
|
+
permissive: true
|
|
549
|
+
}
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
const introspectScript = path.join(__dirname, "schema", "introspect-db.ts");
|
|
553
|
+
if (!fs.existsSync(introspectScript)) {
|
|
554
|
+
console.error(chalk.red(`✗ Could not find introspect-db.ts at ${introspectScript}`));
|
|
555
|
+
process.exit(1);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const tsxBin = resolveLocalBin("tsx");
|
|
559
|
+
if (!tsxBin) {
|
|
560
|
+
console.error(chalk.red("✗ Could not find tsx binary."));
|
|
561
|
+
process.exit(1);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const outputPath = argsList["--output"] || path.join("config", "collections");
|
|
565
|
+
|
|
566
|
+
console.log("");
|
|
567
|
+
console.log(chalk.bold(" 🔍 Rebase Schema Introspector"));
|
|
568
|
+
console.log("");
|
|
569
|
+
|
|
570
|
+
const cmdParts = [
|
|
571
|
+
tsxBin,
|
|
572
|
+
introspectScript,
|
|
573
|
+
`--output=${outputPath}`,
|
|
574
|
+
...(argsList["--force"] ? ["--force"] : []),
|
|
575
|
+
...(argsList["--schema"] ? [`--schema=${argsList["--schema"]}`] : [])
|
|
576
|
+
];
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
await execa(cmdParts[0], cmdParts.slice(1), {
|
|
580
|
+
cwd: process.cwd(),
|
|
581
|
+
stdio: "inherit",
|
|
582
|
+
env: { ...process.env as Record<string, string> }
|
|
583
|
+
});
|
|
584
|
+
} catch (err: unknown) {
|
|
585
|
+
console.error(chalk.red(`✗ Failed to run schema introspector: ${err instanceof Error ? err.message : String(err)}`));
|
|
586
|
+
process.exit(1);
|
|
587
|
+
}
|
|
588
|
+
} else {
|
|
589
|
+
console.error(chalk.red("Unknown schema command."));
|
|
590
|
+
process.exit(1);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function doctorPluginCommand(rawArgs: string[]): Promise<void> {
|
|
595
|
+
const parsedArgs = arg(
|
|
596
|
+
{
|
|
597
|
+
"--collections": String,
|
|
598
|
+
"--schema": String,
|
|
599
|
+
"-c": "--collections",
|
|
600
|
+
"-s": "--schema"
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
argv: rawArgs.slice(1), // skip "doctor"
|
|
604
|
+
permissive: true
|
|
605
|
+
}
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
const doctorScript = path.join(__dirname, "schema", "doctor-cli.ts");
|
|
609
|
+
if (!fs.existsSync(doctorScript)) {
|
|
610
|
+
console.error(chalk.red(`✗ Could not find doctor.ts at ${doctorScript}`));
|
|
611
|
+
process.exit(1);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const tsxBin = resolveLocalBin("tsx");
|
|
615
|
+
if (!tsxBin) {
|
|
616
|
+
console.error(chalk.red("✗ Could not find tsx binary."));
|
|
617
|
+
process.exit(1);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const collectionsPath = parsedArgs["--collections"] || path.join("..", "shared", "collections");
|
|
621
|
+
const schemaPath = parsedArgs["--schema"] || path.join("src", "schema.generated.ts");
|
|
622
|
+
|
|
623
|
+
const cmdParts = [
|
|
624
|
+
tsxBin,
|
|
625
|
+
doctorScript,
|
|
626
|
+
`--collections=${collectionsPath}`,
|
|
627
|
+
`--schema=${schemaPath}`
|
|
628
|
+
];
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
await execa(cmdParts[0], cmdParts.slice(1), {
|
|
632
|
+
cwd: process.cwd(),
|
|
633
|
+
stdio: "inherit",
|
|
634
|
+
env: { ...process.env as Record<string, string> }
|
|
635
|
+
});
|
|
636
|
+
} catch {
|
|
637
|
+
process.exit(1);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
// Entry point when called directly
|
|
643
|
+
import fsSync from "fs";
|
|
644
|
+
const argv1Real = process.argv[1] ? fsSync.realpathSync(process.argv[1]) : "";
|
|
645
|
+
if (import.meta.url === `file://${argv1Real}`) {
|
|
646
|
+
// Drop node and script path
|
|
647
|
+
runPluginCommand(process.argv.slice(2)).catch(() => process.exit(1));
|
|
648
|
+
}
|