@danhachuel/prodmaker 0.1.10 → 0.1.12

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.
@@ -1,200 +1,237 @@
1
- """Banco SQLite local (stdlib) — helpers compartilhados."""
2
- import sqlite3
3
- import time
4
- from pathlib import Path
1
+ """Banco de dados — façade sobre dbengine (SQLite padrão, PostgreSQL/Supabase via DATABASE_URL).
5
2
 
6
- from .store import STATE
3
+ Esquema único para os dois engines. Todas as tabelas carregam `user_id` (preparação
4
+ multi-tenant — hoje vazio). `ensure_db()` cria o que falta e aplica ALTERs de migração.
5
+ """
6
+ from .dbengine import (
7
+ add_column_if_missing,
8
+ is_postgres,
9
+ now_str, # noqa: F401 (reexportado para os serviços)
10
+ row_to_dict, # noqa: F401 (reexportado para os serviços)
11
+ )
7
12
 
8
- DB_PATH = STATE / "prodmaker.db"
13
+ # ---------------------------------------------------------------------------
14
+ # Esquema — SQLite DDL; para PostgreSQL as mesmas definições são traduzidas.
15
+ # ---------------------------------------------------------------------------
9
16
 
10
-
11
- def get_db():
12
- STATE.mkdir(parents=True, exist_ok=True)
13
- conn = sqlite3.connect(DB_PATH)
14
- conn.row_factory = sqlite3.Row
15
- return conn
16
-
17
-
18
- def ensure_db() -> None:
19
- with get_db() as conn:
20
- conn.execute(
21
- """
22
- CREATE TABLE IF NOT EXISTS produtos (
23
- id INTEGER PRIMARY KEY AUTOINCREMENT,
24
- nome TEXT NOT NULL,
25
- plataforma TEXT NOT NULL DEFAULT 'Outro',
26
- link_afiliado TEXT NOT NULL DEFAULT '',
27
- comissao REAL NOT NULL DEFAULT 0,
28
- preco REAL NOT NULL DEFAULT 0,
29
- nicho TEXT NOT NULL DEFAULT '',
30
- status TEXT NOT NULL DEFAULT 'em_analise',
31
- created_at TEXT NOT NULL,
32
- updated_at TEXT NOT NULL
33
- )
34
- """
17
+ TABLES = {
18
+ "produtos": """
19
+ CREATE TABLE IF NOT EXISTS produtos (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ user_id TEXT NOT NULL DEFAULT '',
22
+ nome TEXT NOT NULL,
23
+ plataforma TEXT NOT NULL DEFAULT 'Outro',
24
+ link_afiliado TEXT NOT NULL DEFAULT '',
25
+ comissao REAL NOT NULL DEFAULT 0,
26
+ preco REAL NOT NULL DEFAULT 0,
27
+ nicho TEXT NOT NULL DEFAULT '',
28
+ status TEXT NOT NULL DEFAULT 'em_analise',
29
+ analysis TEXT NOT NULL DEFAULT '',
30
+ created_at TEXT NOT NULL,
31
+ updated_at TEXT NOT NULL
35
32
  )
36
- conn.execute(
37
- """
38
- CREATE TABLE IF NOT EXISTS infoprodutos (
39
- id INTEGER PRIMARY KEY AUTOINCREMENT,
40
- titulo TEXT NOT NULL,
41
- formato TEXT NOT NULL DEFAULT 'E-book',
42
- nicho TEXT NOT NULL DEFAULT '',
43
- modo TEXT NOT NULL DEFAULT 'rapido',
44
- provider TEXT NOT NULL DEFAULT '',
45
- pdf_path TEXT NOT NULL DEFAULT '',
46
- md_path TEXT NOT NULL DEFAULT '',
47
- created_at TEXT NOT NULL
48
- )
49
- """
33
+ """,
34
+ "infoprodutos": """
35
+ CREATE TABLE IF NOT EXISTS infoprodutos (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ user_id TEXT NOT NULL DEFAULT '',
38
+ titulo TEXT NOT NULL,
39
+ formato TEXT NOT NULL DEFAULT 'E-book',
40
+ nicho TEXT NOT NULL DEFAULT '',
41
+ modo TEXT NOT NULL DEFAULT 'rapido',
42
+ provider TEXT NOT NULL DEFAULT '',
43
+ pdf_path TEXT NOT NULL DEFAULT '',
44
+ md_path TEXT NOT NULL DEFAULT '',
45
+ created_at TEXT NOT NULL
50
46
  )
51
- conn.execute(
52
- """
53
- CREATE TABLE IF NOT EXISTS paginas (
54
- id INTEGER PRIMARY KEY AUTOINCREMENT,
55
- titulo TEXT NOT NULL,
56
- tipo TEXT NOT NULL,
57
- produto_id INTEGER,
58
- idioma TEXT NOT NULL DEFAULT 'Portugues',
59
- html_path TEXT NOT NULL DEFAULT '',
60
- status TEXT NOT NULL DEFAULT 'rascunho',
61
- published_url TEXT NOT NULL DEFAULT '',
62
- created_at TEXT NOT NULL,
63
- updated_at TEXT NOT NULL
64
- )
65
- """
47
+ """,
48
+ "paginas": """
49
+ CREATE TABLE IF NOT EXISTS paginas (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ user_id TEXT NOT NULL DEFAULT '',
52
+ titulo TEXT NOT NULL,
53
+ tipo TEXT NOT NULL,
54
+ produto_id INTEGER,
55
+ idioma TEXT NOT NULL DEFAULT 'Portugues',
56
+ html_path TEXT NOT NULL DEFAULT '',
57
+ status TEXT NOT NULL DEFAULT 'rascunho',
58
+ published_url TEXT NOT NULL DEFAULT '',
59
+ created_at TEXT NOT NULL,
60
+ updated_at TEXT NOT NULL
66
61
  )
67
- conn.execute(
68
- """
69
- CREATE TABLE IF NOT EXISTS dominios (
70
- id INTEGER PRIMARY KEY AUTOINCREMENT,
71
- nome TEXT NOT NULL,
72
- status TEXT NOT NULL DEFAULT 'pendente',
73
- cf_zone_id TEXT NOT NULL DEFAULT '',
74
- ns_data TEXT NOT NULL DEFAULT '',
75
- a_data TEXT NOT NULL DEFAULT '',
76
- tags TEXT NOT NULL DEFAULT '',
77
- created_at TEXT NOT NULL,
78
- updated_at TEXT NOT NULL
79
- )
80
- """
62
+ """,
63
+ "dominios": """
64
+ CREATE TABLE IF NOT EXISTS dominios (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ user_id TEXT NOT NULL DEFAULT '',
67
+ nome TEXT NOT NULL,
68
+ status TEXT NOT NULL DEFAULT 'pendente',
69
+ cf_zone_id TEXT NOT NULL DEFAULT '',
70
+ ns_data TEXT NOT NULL DEFAULT '',
71
+ a_data TEXT NOT NULL DEFAULT '',
72
+ tags TEXT NOT NULL DEFAULT '',
73
+ created_at TEXT NOT NULL,
74
+ updated_at TEXT NOT NULL
81
75
  )
82
- conn.execute(
83
- """
84
- CREATE TABLE IF NOT EXISTS visitas (
85
- id INTEGER PRIMARY KEY AUTOINCREMENT,
86
- event TEXT NOT NULL DEFAULT '',
87
- page TEXT NOT NULL DEFAULT '',
88
- gclid TEXT NOT NULL DEFAULT '',
89
- utm_source TEXT NOT NULL DEFAULT '',
90
- utm_medium TEXT NOT NULL DEFAULT '',
91
- utm_campaign TEXT NOT NULL DEFAULT '',
92
- ref TEXT NOT NULL DEFAULT '',
93
- ip TEXT NOT NULL DEFAULT '',
94
- user_agent TEXT NOT NULL DEFAULT '',
95
- extra TEXT NOT NULL DEFAULT '',
96
- created_at TEXT NOT NULL
97
- )
98
- """
76
+ """,
77
+ "visitas": """
78
+ CREATE TABLE IF NOT EXISTS visitas (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ user_id TEXT NOT NULL DEFAULT '',
81
+ event TEXT NOT NULL DEFAULT '',
82
+ page TEXT NOT NULL DEFAULT '',
83
+ gclid TEXT NOT NULL DEFAULT '',
84
+ utm_source TEXT NOT NULL DEFAULT '',
85
+ utm_medium TEXT NOT NULL DEFAULT '',
86
+ utm_campaign TEXT NOT NULL DEFAULT '',
87
+ ref TEXT NOT NULL DEFAULT '',
88
+ ip TEXT NOT NULL DEFAULT '',
89
+ user_agent TEXT NOT NULL DEFAULT '',
90
+ extra TEXT NOT NULL DEFAULT '',
91
+ created_at TEXT NOT NULL
99
92
  )
100
- conn.execute(
101
- """
102
- CREATE TABLE IF NOT EXISTS vendas (
103
- id INTEGER PRIMARY KEY AUTOINCREMENT,
104
- fonte TEXT NOT NULL DEFAULT 'manual',
105
- produto_id INTEGER,
106
- produto_nome TEXT NOT NULL DEFAULT '',
107
- transacao_id TEXT NOT NULL DEFAULT '',
108
- comprador_email TEXT NOT NULL DEFAULT '',
109
- valor REAL NOT NULL DEFAULT 0,
110
- comissao REAL NOT NULL DEFAULT 0,
111
- gclid TEXT NOT NULL DEFAULT '',
112
- utm_source TEXT NOT NULL DEFAULT '',
113
- utm_medium TEXT NOT NULL DEFAULT '',
114
- utm_campaign TEXT NOT NULL DEFAULT '',
115
- status TEXT NOT NULL DEFAULT 'aprovada',
116
- conversao_status TEXT NOT NULL DEFAULT 'pendente',
117
- conversao_erro TEXT NOT NULL DEFAULT '',
118
- gclid_match TEXT NOT NULL DEFAULT '',
119
- raw TEXT NOT NULL DEFAULT '',
120
- created_at TEXT NOT NULL
121
- )
122
- """
93
+ """,
94
+ "vendas": """
95
+ CREATE TABLE IF NOT EXISTS vendas (
96
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
97
+ user_id TEXT NOT NULL DEFAULT '',
98
+ fonte TEXT NOT NULL DEFAULT 'manual',
99
+ produto_id INTEGER,
100
+ produto_nome TEXT NOT NULL DEFAULT '',
101
+ transacao_id TEXT NOT NULL DEFAULT '',
102
+ comprador_email TEXT NOT NULL DEFAULT '',
103
+ valor REAL NOT NULL DEFAULT 0,
104
+ comissao REAL NOT NULL DEFAULT 0,
105
+ gclid TEXT NOT NULL DEFAULT '',
106
+ utm_source TEXT NOT NULL DEFAULT '',
107
+ utm_medium TEXT NOT NULL DEFAULT '',
108
+ utm_campaign TEXT NOT NULL DEFAULT '',
109
+ status TEXT NOT NULL DEFAULT 'aprovada',
110
+ conversao_status TEXT NOT NULL DEFAULT 'pendente',
111
+ conversao_erro TEXT NOT NULL DEFAULT '',
112
+ gclid_match TEXT NOT NULL DEFAULT '',
113
+ raw TEXT NOT NULL DEFAULT '',
114
+ created_at TEXT NOT NULL
123
115
  )
124
- conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_gclid ON vendas (gclid)")
125
- conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_created ON vendas (created_at)")
126
- conn.execute("CREATE INDEX IF NOT EXISTS idx_visitas_gclid ON visitas (gclid)")
127
- conn.execute(
128
- """
129
- CREATE TABLE IF NOT EXISTS palavras (
130
- id INTEGER PRIMARY KEY AUTOINCREMENT,
131
- produto_id INTEGER,
132
- produto_nome TEXT NOT NULL DEFAULT '',
133
- tipo TEXT NOT NULL DEFAULT 'positiva',
134
- texto TEXT NOT NULL,
135
- correspondencia TEXT NOT NULL DEFAULT 'frase',
136
- origem TEXT NOT NULL DEFAULT 'ia',
137
- created_at TEXT NOT NULL
138
- )
139
- """
116
+ """,
117
+ "palavras": """
118
+ CREATE TABLE IF NOT EXISTS palavras (
119
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
120
+ user_id TEXT NOT NULL DEFAULT '',
121
+ produto_id INTEGER,
122
+ produto_nome TEXT NOT NULL DEFAULT '',
123
+ tipo TEXT NOT NULL DEFAULT 'positiva',
124
+ texto TEXT NOT NULL,
125
+ correspondencia TEXT NOT NULL DEFAULT 'frase',
126
+ origem TEXT NOT NULL DEFAULT 'ia',
127
+ created_at TEXT NOT NULL
128
+ )
129
+ """,
130
+ "campanhas": """
131
+ CREATE TABLE IF NOT EXISTS campanhas (
132
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
133
+ user_id TEXT NOT NULL DEFAULT '',
134
+ produto_id INTEGER,
135
+ plataforma TEXT NOT NULL DEFAULT 'google',
136
+ nome TEXT NOT NULL,
137
+ objetivo TEXT NOT NULL DEFAULT '',
138
+ cpa_alvo REAL NOT NULL DEFAULT 0,
139
+ orcamento_diario REAL NOT NULL DEFAULT 0,
140
+ keywords_json TEXT NOT NULL DEFAULT '[]',
141
+ negativas_json TEXT NOT NULL DEFAULT '[]',
142
+ criativos_json TEXT NOT NULL DEFAULT '[]',
143
+ plano_json TEXT NOT NULL DEFAULT '',
144
+ status TEXT NOT NULL DEFAULT 'rascunho',
145
+ resultado_json TEXT NOT NULL DEFAULT '',
146
+ erro TEXT NOT NULL DEFAULT '',
147
+ created_at TEXT NOT NULL,
148
+ updated_at TEXT NOT NULL
140
149
  )
141
- conn.execute(
142
- """
143
- CREATE TABLE IF NOT EXISTS campanhas (
144
- id INTEGER PRIMARY KEY AUTOINCREMENT,
145
- produto_id INTEGER,
146
- plataforma TEXT NOT NULL DEFAULT 'google',
147
- nome TEXT NOT NULL,
148
- objetivo TEXT NOT NULL DEFAULT '',
149
- cpa_alvo REAL NOT NULL DEFAULT 0,
150
- orcamento_diario REAL NOT NULL DEFAULT 0,
151
- keywords_json TEXT NOT NULL DEFAULT '[]',
152
- negativas_json TEXT NOT NULL DEFAULT '[]',
153
- criativos_json TEXT NOT NULL DEFAULT '[]',
154
- plano_json TEXT NOT NULL DEFAULT '',
155
- status TEXT NOT NULL DEFAULT 'rascunho',
156
- resultado_json TEXT NOT NULL DEFAULT '',
157
- erro TEXT NOT NULL DEFAULT '',
158
- created_at TEXT NOT NULL,
159
- updated_at TEXT NOT NULL
160
- )
161
- """
150
+ """,
151
+ "lancamentos": """
152
+ CREATE TABLE IF NOT EXISTS lancamentos (
153
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
154
+ user_id TEXT NOT NULL DEFAULT '',
155
+ produto_id INTEGER,
156
+ etapa_atual INTEGER NOT NULL DEFAULT 1,
157
+ status TEXT NOT NULL DEFAULT 'em_andamento',
158
+ dados_json TEXT NOT NULL DEFAULT '{}',
159
+ created_at TEXT NOT NULL,
160
+ updated_at TEXT NOT NULL
162
161
  )
163
- columns = [row["name"] for row in conn.execute("PRAGMA table_info(produtos)")]
164
- if "analysis" not in columns:
165
- conn.execute("ALTER TABLE produtos ADD COLUMN analysis TEXT NOT NULL DEFAULT ''")
166
- conn.execute(
167
- """
168
- CREATE TABLE IF NOT EXISTS lancamentos (
169
- id INTEGER PRIMARY KEY AUTOINCREMENT,
170
- produto_id INTEGER,
171
- etapa_atual INTEGER NOT NULL DEFAULT 1,
172
- status TEXT NOT NULL DEFAULT 'em_andamento',
173
- dados_json TEXT NOT NULL DEFAULT '{}',
174
- created_at TEXT NOT NULL,
175
- updated_at TEXT NOT NULL
176
- )
177
- """
162
+ """,
163
+ "notificacoes": """
164
+ CREATE TABLE IF NOT EXISTS notificacoes (
165
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
166
+ user_id TEXT NOT NULL DEFAULT '',
167
+ event TEXT NOT NULL DEFAULT '',
168
+ titulo TEXT NOT NULL DEFAULT '',
169
+ mensagem TEXT NOT NULL DEFAULT '',
170
+ canal TEXT NOT NULL DEFAULT '',
171
+ status TEXT NOT NULL DEFAULT 'pendente',
172
+ erro TEXT NOT NULL DEFAULT '',
173
+ created_at TEXT NOT NULL
178
174
  )
179
- conn.execute(
180
- """
181
- CREATE TABLE IF NOT EXISTS notificacoes (
182
- id INTEGER PRIMARY KEY AUTOINCREMENT,
183
- event TEXT NOT NULL DEFAULT '',
184
- titulo TEXT NOT NULL DEFAULT '',
185
- mensagem TEXT NOT NULL DEFAULT '',
186
- canal TEXT NOT NULL DEFAULT '',
187
- status TEXT NOT NULL DEFAULT 'pendente',
188
- erro TEXT NOT NULL DEFAULT '',
189
- created_at TEXT NOT NULL
190
- )
191
- """
175
+ """,
176
+ "pins": """
177
+ CREATE TABLE IF NOT EXISTS pins (
178
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
179
+ user_id TEXT NOT NULL DEFAULT '',
180
+ produto_id INTEGER,
181
+ titulo TEXT NOT NULL,
182
+ descricao TEXT NOT NULL DEFAULT '',
183
+ texto_imagem TEXT NOT NULL DEFAULT '',
184
+ sugestao_imagem TEXT NOT NULL DEFAULT '',
185
+ board TEXT NOT NULL DEFAULT '',
186
+ link TEXT NOT NULL DEFAULT '',
187
+ status TEXT NOT NULL DEFAULT 'ideia',
188
+ provider TEXT NOT NULL DEFAULT '',
189
+ erro TEXT NOT NULL DEFAULT '',
190
+ created_at TEXT NOT NULL
192
191
  )
192
+ """,
193
+ "investimentos": """
194
+ CREATE TABLE IF NOT EXISTS investimentos (
195
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
196
+ user_id TEXT NOT NULL DEFAULT '',
197
+ data TEXT NOT NULL DEFAULT '',
198
+ valor REAL NOT NULL DEFAULT 0,
199
+ fonte TEXT NOT NULL DEFAULT 'manual',
200
+ observacao TEXT NOT NULL DEFAULT '',
201
+ created_at TEXT NOT NULL
202
+ )
203
+ """,
204
+ }
205
+
206
+ ALL_TABLES = list(TABLES.keys())
193
207
 
194
208
 
195
- def now_str() -> str:
196
- return time.strftime("%Y-%m-%d %H:%M:%S")
209
+ def _pg_ddl(sqlite_ddl: str) -> str:
210
+ """Traduz o DDL SQLite para PostgreSQL."""
211
+ return sqlite_ddl.replace("INTEGER PRIMARY KEY AUTOINCREMENT", "BIGSERIAL PRIMARY KEY")
212
+
213
+
214
+ def get_db():
215
+ from .dbengine import connect
216
+
217
+ return connect()
218
+
219
+
220
+ def ensure_db() -> None:
221
+ with get_db() as conn:
222
+ if is_postgres():
223
+ for ddl in TABLES.values():
224
+ conn.execute(_pg_ddl(ddl))
225
+ else:
226
+ for ddl in TABLES.values():
227
+ conn.execute(ddl)
228
+ # Migração: bancos criados antes da coluna user_id / da tabela investimentos
229
+ for table in ALL_TABLES:
230
+ add_column_if_missing(conn, table, "user_id TEXT NOT NULL DEFAULT ''", "user_id")
231
+ if "investimentos" not in [row["name"] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]:
232
+ conn.execute(TABLES["investimentos"])
233
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_vendas_gclid ON vendas (gclid)")
234
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_visitas_gclid ON visitas (gclid)")
197
235
 
198
236
 
199
- def row_to_dict(row) -> dict:
200
- return {key: row[key] for key in row.keys()}
237
+ ensure_db()
@@ -0,0 +1,195 @@
1
+ """Camada de abstração de banco — SQLite (padrão) + PostgreSQL/Supabase (DATABASE_URL).
2
+
3
+ Objetivo (radar Supabase): o sistema aceita `DATABASE_URL` apontando para o PostgreSQL
4
+ do Supabase SEM quebrar a compatibilidade local com SQLite. Todos os serviços continuam
5
+ usando `get_db()` com a mesma API (execute/fetchall/fetchone, context manager, rows
6
+ acessíveis por nome de coluna). Para PostgreSQL os placeholders `?` são traduzidos para
7
+ `%s` e as linhas viram dicts (mesmo acesso por nome).
8
+
9
+ Multi-tenant (preparado, NÃO implementado): todas as tabelas têm `user_id TEXT DEFAULT ''`.
10
+ Quando o Supabase Auth entrar, basta filtrar por `current_user_id()` (hoje retorna '').
11
+ """
12
+ import os
13
+ import sqlite3
14
+ import time
15
+ import uuid
16
+ from pathlib import Path
17
+
18
+ from .store import STATE
19
+
20
+
21
+ def now_str() -> str:
22
+ return time.strftime("%Y-%m-%d %H:%M:%S")
23
+
24
+
25
+ def row_to_dict(row) -> dict:
26
+ if row is None:
27
+ return {}
28
+ if isinstance(row, dict):
29
+ return dict(row)
30
+ return {key: row[key] for key in row.keys()}
31
+
32
+
33
+ def dsn() -> str | None:
34
+ return (os.environ.get("DATABASE_URL") or "").strip() or None
35
+
36
+
37
+ def is_postgres() -> bool:
38
+ value = dsn()
39
+ return bool(value and value.startswith(("postgres://", "postgresql://")))
40
+
41
+
42
+ def engine_name() -> str:
43
+ return "postgres" if is_postgres() else "sqlite"
44
+
45
+
46
+ def current_user_id() -> str:
47
+ """Placeholder multi-tenant: vazio enquanto não houver Supabase Auth."""
48
+ return (os.environ.get("PRODMAKER_USER_ID") or "").strip()
49
+
50
+
51
+ def new_user_id() -> str:
52
+ return str(uuid.uuid4())
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # PostgreSQL — wrapper mínimo (imports preguiçosos de psycopg)
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ def _translate(sql: str) -> str:
61
+ return sql.replace("?", "%s")
62
+
63
+
64
+ class _PgRow(dict):
65
+ """Acesso por nome (row["col"]) como sqlite3.Row."""
66
+
67
+
68
+ class _PgResult:
69
+ def __init__(self, cursor):
70
+ self._cursor = cursor
71
+ self._description = cursor.description
72
+ self._rows = None
73
+
74
+ def _materialize(self):
75
+ if self._rows is None:
76
+ columns = [col.name for col in self._description] if self._description else []
77
+ self._rows = [_PgRow(zip(columns, values)) for values in (self._cursor.fetchall() if self._description else [])]
78
+
79
+ def fetchall(self):
80
+ self._materialize()
81
+ return list(self._rows)
82
+
83
+ def fetchone(self):
84
+ self._materialize()
85
+ return self._rows[0] if self._rows else None
86
+
87
+ @property
88
+ def lastrowid(self):
89
+ # PostgreSQL: capturamos o id via RETURNING nas inserções específicas
90
+ self._materialize()
91
+ row = self._rows[0] if self._rows else None
92
+ return row.get("id") if row else None
93
+
94
+ def __iter__(self):
95
+ self._materialize()
96
+ return iter(self._rows)
97
+
98
+
99
+ class _PgConn:
100
+ def __init__(self, connection):
101
+ self._conn = connection
102
+
103
+ def execute(self, sql: str, params=()):
104
+ cursor = self._conn.cursor()
105
+ cursor.execute(_translate(sql), tuple(params or ()))
106
+ return _PgResult(cursor)
107
+
108
+ def executescript(self, sql: str):
109
+ cursor = self._conn.cursor()
110
+ cursor.execute(sql)
111
+ return _PgResult(cursor)
112
+
113
+ def commit(self):
114
+ self._conn.commit()
115
+
116
+ def close(self):
117
+ try:
118
+ self._conn.rollback()
119
+ except Exception: # noqa: BLE001
120
+ pass
121
+ self._conn.close()
122
+
123
+ def __enter__(self):
124
+ return self
125
+
126
+ def __exit__(self, exc_type, exc, tb):
127
+ if exc_type is None:
128
+ self._conn.commit()
129
+ else:
130
+ self._conn.rollback()
131
+ self.close()
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Conexão
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ def _sqlite_path():
140
+ STATE.mkdir(parents=True, exist_ok=True)
141
+ return STATE / "prodmaker.db"
142
+
143
+
144
+ def connect():
145
+ """Conexão do engine ativo (sqlite padrão; postgres quando DATABASE_URL definido)."""
146
+ if is_postgres():
147
+ try:
148
+ import psycopg
149
+ except ImportError as exc:
150
+ raise RuntimeError(
151
+ "DATABASE_URL definido, mas o driver psycopg não está instalado — "
152
+ "rode 'pip install psycopg[binary]' (ou 'prodmaker install' com o requirements atualizado)."
153
+ ) from exc
154
+ try:
155
+ connection = psycopg.connect(dsn(), connect_timeout=10)
156
+ except Exception as exc: # noqa: BLE001
157
+ raise RuntimeError(
158
+ f"Falha ao conectar no PostgreSQL (DATABASE_URL): {exc}. "
159
+ "Confira a connection string do Supabase ou desdefina DATABASE_URL para voltar ao SQLite local."
160
+ ) from exc
161
+ return _PgConn(connection)
162
+ conn = sqlite3.connect(_sqlite_path())
163
+ conn.row_factory = sqlite3.Row
164
+ return conn
165
+
166
+
167
+ # Compatibilidade com o nome antigo usado pelos serviços
168
+ def get_db():
169
+ return connect()
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Introspecção / migração (dialect-aware)
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ def table_columns(conn, table: str) -> list[str]:
178
+ if is_postgres():
179
+ rows = (
180
+ conn.execute(
181
+ "SELECT column_name AS name FROM information_schema.columns WHERE table_name = %s",
182
+ (table,),
183
+ )
184
+ .fetchall()
185
+ )
186
+ return [row["name"] for row in rows]
187
+ rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
188
+ return [row["name"] for row in rows]
189
+
190
+
191
+ def add_column_if_missing(conn, table: str, column_ddl: str, column_name: str) -> bool:
192
+ if column_name not in table_columns(conn, table):
193
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column_ddl}")
194
+ return True
195
+ return False