@palettelab/cli 0.3.29 → 0.3.30
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/README.md +4 -3
- package/backend-sdk/palette_sdk/__init__.py +11 -1
- package/backend-sdk/palette_sdk/db/__init__.py +18 -5
- package/backend-sdk/palette_sdk/db/base.py +17 -0
- package/docs/python-backend-sdk.md +10 -8
- package/lib/commands/build.js +42 -6
- package/lib/commands/init.js +11 -2
- package/lib/commands/test.js +1 -1
- package/lib/manifest.js +22 -0
- package/package.json +1 -1
- package/template-fallback/templates/database/backend/api/models.py +1 -1
- package/template-fallback/templates/database/backend/migrations/versions/001_init.py +5 -5
package/README.md
CHANGED
|
@@ -224,7 +224,8 @@ Declare database ownership in `palette-plugin.json`:
|
|
|
224
224
|
}
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
-
Define org-scoped models with the backend SDK
|
|
227
|
+
Define org-scoped models with the backend SDK. Table names must start with the
|
|
228
|
+
app prefix (`my_app__` for `my-app`):
|
|
228
229
|
|
|
229
230
|
```python
|
|
230
231
|
from sqlalchemy import String
|
|
@@ -232,7 +233,7 @@ from sqlalchemy.orm import Mapped, mapped_column
|
|
|
232
233
|
from palette_sdk import OrgScopedTable
|
|
233
234
|
|
|
234
235
|
class Invoice(OrgScopedTable):
|
|
235
|
-
__tablename__ = "
|
|
236
|
+
__tablename__ = "my_app__invoices"
|
|
236
237
|
|
|
237
238
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
238
239
|
customer_name: Mapped[str] = mapped_column(String(255))
|
|
@@ -245,7 +246,7 @@ from palette_sdk.db import ensure_org_rls
|
|
|
245
246
|
|
|
246
247
|
def upgrade():
|
|
247
248
|
op.create_table(...)
|
|
248
|
-
ensure_org_rls(op, "
|
|
249
|
+
ensure_org_rls(op, "my_app__invoices")
|
|
249
250
|
```
|
|
250
251
|
|
|
251
252
|
Use `ctx.repo(Model)` for org-safe CRUD:
|
|
@@ -12,7 +12,14 @@ from palette_sdk.schemas import (
|
|
|
12
12
|
ErrorResponse,
|
|
13
13
|
PaginatedResponse,
|
|
14
14
|
)
|
|
15
|
-
from palette_sdk.db import
|
|
15
|
+
from palette_sdk.db import (
|
|
16
|
+
OrgScopedTable,
|
|
17
|
+
PluginBase,
|
|
18
|
+
ensure_org_rls,
|
|
19
|
+
plugin_safe_id,
|
|
20
|
+
plugin_schema,
|
|
21
|
+
plugin_table_prefix,
|
|
22
|
+
)
|
|
16
23
|
from palette_sdk.permissions import (
|
|
17
24
|
KNOWN_PERMISSIONS,
|
|
18
25
|
is_known_permission,
|
|
@@ -39,6 +46,9 @@ __all__ = [
|
|
|
39
46
|
"OrgScopedTable",
|
|
40
47
|
"PluginBase",
|
|
41
48
|
"ensure_org_rls",
|
|
49
|
+
"plugin_safe_id",
|
|
50
|
+
"plugin_schema",
|
|
51
|
+
"plugin_table_prefix",
|
|
42
52
|
"KNOWN_PERMISSIONS",
|
|
43
53
|
"is_known_permission",
|
|
44
54
|
"require_permission",
|
|
@@ -12,7 +12,7 @@ Typical plugin usage:
|
|
|
12
12
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
13
13
|
|
|
14
14
|
class Expense(OrgScopedTable):
|
|
15
|
-
__tablename__ = "
|
|
15
|
+
__tablename__ = "my_app__expenses"
|
|
16
16
|
title: Mapped[str] = mapped_column(String(200))
|
|
17
17
|
amount_cents: Mapped[int]
|
|
18
18
|
|
|
@@ -23,16 +23,29 @@ Typical plugin usage:
|
|
|
23
23
|
|
|
24
24
|
def upgrade():
|
|
25
25
|
op.create_table(
|
|
26
|
-
"
|
|
26
|
+
"my_app__expenses",
|
|
27
27
|
sa.Column("id", sa.Integer, primary_key=True),
|
|
28
28
|
sa.Column("organization_id", sa.BigInteger, nullable=False, index=True),
|
|
29
29
|
sa.Column("title", sa.String(200), nullable=False),
|
|
30
30
|
sa.Column("amount_cents", sa.Integer, nullable=False),
|
|
31
31
|
)
|
|
32
|
-
ensure_org_rls(op, "
|
|
32
|
+
ensure_org_rls(op, "my_app__expenses")
|
|
33
33
|
"""
|
|
34
34
|
|
|
35
|
-
from palette_sdk.db.base import
|
|
35
|
+
from palette_sdk.db.base import (
|
|
36
|
+
OrgScopedTable,
|
|
37
|
+
PluginBase,
|
|
38
|
+
plugin_safe_id,
|
|
39
|
+
plugin_schema,
|
|
40
|
+
plugin_table_prefix,
|
|
41
|
+
)
|
|
36
42
|
from palette_sdk.db.rls import ensure_org_rls
|
|
37
43
|
|
|
38
|
-
__all__ = [
|
|
44
|
+
__all__ = [
|
|
45
|
+
"OrgScopedTable",
|
|
46
|
+
"PluginBase",
|
|
47
|
+
"ensure_org_rls",
|
|
48
|
+
"plugin_safe_id",
|
|
49
|
+
"plugin_schema",
|
|
50
|
+
"plugin_table_prefix",
|
|
51
|
+
]
|
|
@@ -11,6 +11,23 @@ from sqlalchemy import BigInteger, Index
|
|
|
11
11
|
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def plugin_safe_id(plugin_id: str) -> str:
|
|
15
|
+
"""Return the DB-safe form of a plugin id."""
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
return re.sub(r"[^a-z0-9_]", "_", plugin_id.lower())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def plugin_schema(plugin_id: str) -> str:
|
|
22
|
+
"""Return the Postgres schema name owned by a plugin."""
|
|
23
|
+
return f"app_{plugin_safe_id(plugin_id)}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def plugin_table_prefix(plugin_id: str) -> str:
|
|
27
|
+
"""Return the required table-name prefix for plugin-owned tables."""
|
|
28
|
+
return f"{plugin_safe_id(plugin_id)}__"
|
|
29
|
+
|
|
30
|
+
|
|
14
31
|
class PluginBase(DeclarativeBase):
|
|
15
32
|
"""Declarative base for plugin models.
|
|
16
33
|
|
|
@@ -183,7 +183,7 @@ from palette_sdk import OrgScopedTable
|
|
|
183
183
|
|
|
184
184
|
|
|
185
185
|
class Invoice(OrgScopedTable):
|
|
186
|
-
__tablename__ = "
|
|
186
|
+
__tablename__ = "finance_tools__invoices"
|
|
187
187
|
|
|
188
188
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
189
189
|
customer_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
@@ -191,8 +191,10 @@ class Invoice(OrgScopedTable):
|
|
|
191
191
|
status: Mapped[str] = mapped_column(String(32), default="draft")
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
-
`OrgScopedTable` adds `organization_id` automatically.
|
|
195
|
-
|
|
194
|
+
`OrgScopedTable` adds `organization_id` automatically. Table names must start
|
|
195
|
+
with the app prefix (`finance_tools__` for `finance-tools`) so app-owned data is
|
|
196
|
+
easy to identify and validate. Do not manually set `organization_id` in route
|
|
197
|
+
code; `ctx.repo(Model)` handles it.
|
|
196
198
|
|
|
197
199
|
### Migration Example
|
|
198
200
|
|
|
@@ -210,22 +212,22 @@ down_revision = None
|
|
|
210
212
|
|
|
211
213
|
def upgrade():
|
|
212
214
|
op.create_table(
|
|
213
|
-
"
|
|
215
|
+
"finance_tools__invoices",
|
|
214
216
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
215
217
|
sa.Column("organization_id", sa.BigInteger(), nullable=False),
|
|
216
218
|
sa.Column("customer_name", sa.String(length=255), nullable=False),
|
|
217
219
|
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
|
|
218
220
|
sa.Column("status", sa.String(length=32), nullable=False),
|
|
219
221
|
)
|
|
220
|
-
op.create_index("
|
|
221
|
-
ensure_org_rls(op, "
|
|
222
|
+
op.create_index("ix_finance_tools__invoices_org", "finance_tools__invoices", ["organization_id"])
|
|
223
|
+
ensure_org_rls(op, "finance_tools__invoices")
|
|
222
224
|
|
|
223
225
|
|
|
224
226
|
def downgrade():
|
|
225
|
-
op.drop_table("
|
|
227
|
+
op.drop_table("finance_tools__invoices")
|
|
226
228
|
```
|
|
227
229
|
|
|
228
|
-
`ensure_org_rls(op, "
|
|
230
|
+
`ensure_org_rls(op, "finance_tools__invoices")` enables row-level security so each
|
|
229
231
|
organization can only access its own rows.
|
|
230
232
|
|
|
231
233
|
## 7. Repository CRUD
|
package/lib/commands/build.js
CHANGED
|
@@ -19,9 +19,35 @@ const BANNED_PATTERNS = [
|
|
|
19
19
|
{ re: /\bDROP\s+SCHEMA\b/i, reason: "DROP SCHEMA is not allowed in plugin migrations" },
|
|
20
20
|
]
|
|
21
21
|
|
|
22
|
-
function
|
|
22
|
+
function pluginSafeId(pluginId) {
|
|
23
|
+
return String(pluginId || "").toLowerCase().replace(/[^a-z0-9_]/g, "_")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pluginSchema(pluginId) {
|
|
27
|
+
return `app_${pluginSafeId(pluginId)}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pluginTablePrefix(pluginId) {
|
|
31
|
+
return `${pluginSafeId(pluginId)}__`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function crossPluginSchemaRefs(src, allowedSchema) {
|
|
35
|
+
if (!allowedSchema) return []
|
|
36
|
+
const refs = new Set()
|
|
37
|
+
const re = /(^|[^a-z0-9_])"?((?:app)_[a-z0-9_]+)"?\s*\./gi
|
|
38
|
+
let match
|
|
39
|
+
while ((match = re.exec(src)) !== null) {
|
|
40
|
+
const schema = match[2].toLowerCase()
|
|
41
|
+
if (schema !== allowedSchema) refs.add(schema)
|
|
42
|
+
}
|
|
43
|
+
return [...refs].sort()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function lintMigrationFile(absPath, pluginId) {
|
|
23
47
|
const issues = []
|
|
24
48
|
const src = fs.readFileSync(absPath, "utf8")
|
|
49
|
+
const requiredPrefix = pluginId ? pluginTablePrefix(pluginId) : null
|
|
50
|
+
const allowedSchema = pluginId ? pluginSchema(pluginId) : null
|
|
25
51
|
|
|
26
52
|
for (const { re, reason } of BANNED_PATTERNS) {
|
|
27
53
|
if (re.test(src)) {
|
|
@@ -29,12 +55,16 @@ function lintMigrationFile(absPath) {
|
|
|
29
55
|
}
|
|
30
56
|
}
|
|
31
57
|
|
|
58
|
+
for (const schema of crossPluginSchemaRefs(src, allowedSchema)) {
|
|
59
|
+
issues.push(`${path.basename(absPath)}: plugin migrations must not reference another app schema (${schema})`)
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
// Every op.create_table("foo", ...) in the file must have a matching
|
|
33
63
|
// ensure_org_rls(op, "foo") somewhere in the same file. Caveat: this is a
|
|
34
64
|
// cheap syntactic check, not a full AST walk. If your table name is dynamic
|
|
35
65
|
// or your migration is unusual, you can silence the check by adding the
|
|
36
66
|
// magic comment `# palette:rls-ok` on the same logical migration.
|
|
37
|
-
|
|
67
|
+
const skipRlsCheck = /#\s*palette:rls-ok\b/.test(src)
|
|
38
68
|
|
|
39
69
|
const tableNames = new Set()
|
|
40
70
|
const createTableRe = /op\.create_table\(\s*['"]([a-zA-Z_][a-zA-Z0-9_]*)['"]/g
|
|
@@ -44,8 +74,13 @@ function lintMigrationFile(absPath) {
|
|
|
44
74
|
}
|
|
45
75
|
|
|
46
76
|
for (const name of tableNames) {
|
|
77
|
+
if (requiredPrefix && !name.startsWith(requiredPrefix)) {
|
|
78
|
+
issues.push(
|
|
79
|
+
`${path.basename(absPath)}: create_table("${name}") must use the app table prefix "${requiredPrefix}"`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
47
82
|
const rlsRe = new RegExp(`ensure_org_rls\\(\\s*op\\s*,\\s*['"]${name}['"]`)
|
|
48
|
-
if (!rlsRe.test(src)) {
|
|
83
|
+
if (!skipRlsCheck && !rlsRe.test(src)) {
|
|
49
84
|
issues.push(
|
|
50
85
|
`${path.basename(absPath)}: create_table("${name}") is missing ensure_org_rls(op, "${name}"). ` +
|
|
51
86
|
`Inherit from OrgScopedTable and call ensure_org_rls, or mark the migration with # palette:rls-ok if the table is intentionally global.`,
|
|
@@ -56,7 +91,7 @@ function lintMigrationFile(absPath) {
|
|
|
56
91
|
return issues
|
|
57
92
|
}
|
|
58
93
|
|
|
59
|
-
function lintMigrationsDir(migrationsDir) {
|
|
94
|
+
function lintMigrationsDir(migrationsDir, pluginId) {
|
|
60
95
|
const errors = []
|
|
61
96
|
const versionsDir = path.join(migrationsDir, "versions")
|
|
62
97
|
if (!fs.existsSync(versionsDir)) {
|
|
@@ -66,7 +101,7 @@ function lintMigrationsDir(migrationsDir) {
|
|
|
66
101
|
for (const entry of fs.readdirSync(versionsDir)) {
|
|
67
102
|
if (!entry.endsWith(".py")) continue
|
|
68
103
|
const abs = path.join(versionsDir, entry)
|
|
69
|
-
errors.push(...lintMigrationFile(abs))
|
|
104
|
+
errors.push(...lintMigrationFile(abs, pluginId))
|
|
70
105
|
}
|
|
71
106
|
return errors
|
|
72
107
|
}
|
|
@@ -92,7 +127,7 @@ async function run(args, { cwd }) {
|
|
|
92
127
|
} else if (!fs.existsSync(path.join(migrationsAbs, "env.py"))) {
|
|
93
128
|
errors.push(`database.migrations directory is missing env.py: ${migrationsRel}`)
|
|
94
129
|
} else {
|
|
95
|
-
errors.push(...lintMigrationsDir(migrationsAbs))
|
|
130
|
+
errors.push(...lintMigrationsDir(migrationsAbs, manifest.id))
|
|
96
131
|
}
|
|
97
132
|
}
|
|
98
133
|
|
|
@@ -107,3 +142,4 @@ async function run(args, { cwd }) {
|
|
|
107
142
|
module.exports = run
|
|
108
143
|
module.exports.lintMigrationsDir = lintMigrationsDir
|
|
109
144
|
module.exports.lintMigrationFile = lintMigrationFile
|
|
145
|
+
module.exports.pluginTablePrefix = pluginTablePrefix
|
package/lib/commands/init.js
CHANGED
|
@@ -19,6 +19,10 @@ function toSlug(name) {
|
|
|
19
19
|
.replace(/^-|-$/g, "")
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function toDbSafeId(pluginId) {
|
|
23
|
+
return pluginId.toLowerCase().replace(/[^a-z0-9_]/g, "_")
|
|
24
|
+
}
|
|
25
|
+
|
|
22
26
|
function fetchTemplate(destDir) {
|
|
23
27
|
const git = spawnSync(
|
|
24
28
|
"git",
|
|
@@ -69,11 +73,16 @@ function rewriteScaffold(destDir, slug, displayName) {
|
|
|
69
73
|
if (!fs.existsSync(manifestPath)) return
|
|
70
74
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"))
|
|
71
75
|
const originalId = manifest.id
|
|
76
|
+
const originalDbSafeId = originalId ? toDbSafeId(originalId) : null
|
|
77
|
+
const nextDbSafeId = toDbSafeId(slug)
|
|
72
78
|
if (originalId && originalId !== slug) {
|
|
73
79
|
walkFiles(destDir, (filePath) => {
|
|
74
80
|
try {
|
|
75
81
|
const before = fs.readFileSync(filePath, "utf8")
|
|
76
|
-
|
|
82
|
+
let after = before.split(originalId).join(slug)
|
|
83
|
+
if (originalDbSafeId && originalDbSafeId !== nextDbSafeId) {
|
|
84
|
+
after = after.split(originalDbSafeId).join(nextDbSafeId)
|
|
85
|
+
}
|
|
77
86
|
if (after !== before) fs.writeFileSync(filePath, after)
|
|
78
87
|
} catch (_err) {
|
|
79
88
|
// Ignore non-text files. Templates are expected to be text, but this
|
|
@@ -85,7 +94,7 @@ function rewriteScaffold(destDir, slug, displayName) {
|
|
|
85
94
|
rewrittenManifest.id = slug
|
|
86
95
|
rewrittenManifest.name = displayName
|
|
87
96
|
if (rewrittenManifest.database?.schema) {
|
|
88
|
-
rewrittenManifest.database.schema = `app_${
|
|
97
|
+
rewrittenManifest.database.schema = `app_${nextDbSafeId}`
|
|
89
98
|
}
|
|
90
99
|
fs.writeFileSync(manifestPath, JSON.stringify(rewrittenManifest, null, 2) + "\n")
|
|
91
100
|
}
|
package/lib/commands/test.js
CHANGED
|
@@ -252,7 +252,7 @@ function lintMigrations(cwd, manifest, out) {
|
|
|
252
252
|
if (!fs.existsSync(path.join(migrationsAbs, "env.py"))) {
|
|
253
253
|
return out.fail(`database.migrations directory is missing env.py: ${migrationsRel}`)
|
|
254
254
|
}
|
|
255
|
-
const errors = buildCommand.lintMigrationsDir(migrationsAbs)
|
|
255
|
+
const errors = buildCommand.lintMigrationsDir(migrationsAbs, manifest.id)
|
|
256
256
|
for (const err of errors) out.fail(err)
|
|
257
257
|
if (errors.length === 0) out.ok("migration lint passed")
|
|
258
258
|
return errors.length
|
package/lib/manifest.js
CHANGED
|
@@ -69,6 +69,18 @@ function isObject(v) {
|
|
|
69
69
|
return v !== null && typeof v === "object" && !Array.isArray(v)
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
function pluginSafeId(pluginId) {
|
|
73
|
+
return String(pluginId || "").toLowerCase().replace(/[^a-z0-9_]/g, "_")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function expectedPluginSchema(pluginId) {
|
|
77
|
+
return `app_${pluginSafeId(pluginId)}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function expectedTablePrefix(pluginId) {
|
|
81
|
+
return `${pluginSafeId(pluginId)}__`
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
function unknownKeys(obj, allowed, label, errors) {
|
|
73
85
|
if (!isObject(obj)) return
|
|
74
86
|
for (const key of Object.keys(obj)) {
|
|
@@ -249,11 +261,21 @@ function validateManifest(m) {
|
|
|
249
261
|
}
|
|
250
262
|
if (m.database.schema !== undefined && !/^app_[a-z0-9_]+$/.test(m.database.schema)) {
|
|
251
263
|
errors.push("database.schema must match /^app_[a-z0-9_]+$/")
|
|
264
|
+
} else if (m.database.schema !== undefined && m.id && m.database.schema !== expectedPluginSchema(m.id)) {
|
|
265
|
+
errors.push(`database.schema must be ${expectedPluginSchema(m.id)} for plugin ${m.id}`)
|
|
252
266
|
}
|
|
253
267
|
}
|
|
254
268
|
}
|
|
255
269
|
|
|
256
270
|
validateArray(m.shared_tables, "shared_tables", errors)
|
|
271
|
+
if (Array.isArray(m.shared_tables) && m.id) {
|
|
272
|
+
const prefix = expectedTablePrefix(m.id)
|
|
273
|
+
for (const table of m.shared_tables) {
|
|
274
|
+
if (typeof table === "string" && !table.startsWith(prefix)) {
|
|
275
|
+
errors.push(`shared_tables entries must use the app table prefix "${prefix}": ${table}`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
257
279
|
validateArray(m.permissions, "permissions", errors)
|
|
258
280
|
if (Array.isArray(m.permissions)) {
|
|
259
281
|
const seen = new Set()
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ from palette_sdk.db import OrgScopedTable
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class Note(OrgScopedTable):
|
|
8
|
-
__tablename__ = "
|
|
8
|
+
__tablename__ = "my_db_plugin__notes"
|
|
9
9
|
|
|
10
10
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
11
11
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
|
@@ -11,15 +11,15 @@ down_revision = None
|
|
|
11
11
|
|
|
12
12
|
def upgrade() -> None:
|
|
13
13
|
op.create_table(
|
|
14
|
-
"
|
|
14
|
+
"my_db_plugin__notes",
|
|
15
15
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
16
16
|
sa.Column("organization_id", sa.BigInteger(), nullable=False),
|
|
17
17
|
sa.Column("body", sa.Text(), nullable=False),
|
|
18
18
|
)
|
|
19
|
-
op.create_index("
|
|
20
|
-
ensure_org_rls(op, "
|
|
19
|
+
op.create_index("ix_my_db_plugin__notes_org", "my_db_plugin__notes", ["organization_id"])
|
|
20
|
+
ensure_org_rls(op, "my_db_plugin__notes")
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def downgrade() -> None:
|
|
24
|
-
op.drop_index("
|
|
25
|
-
op.drop_table("
|
|
24
|
+
op.drop_index("ix_my_db_plugin__notes_org", table_name="my_db_plugin__notes")
|
|
25
|
+
op.drop_table("my_db_plugin__notes")
|