@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 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__ = "invoices"
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, "invoices")
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 OrgScopedTable, PluginBase, ensure_org_rls
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__ = "expenses"
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
- "expenses",
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, "expenses")
32
+ ensure_org_rls(op, "my_app__expenses")
33
33
  """
34
34
 
35
- from palette_sdk.db.base import OrgScopedTable, PluginBase
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__ = ["OrgScopedTable", "PluginBase", "ensure_org_rls"]
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__ = "invoices"
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. Do not manually set
195
- `organization_id` in route code; `ctx.repo(Model)` handles it.
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
- "invoices",
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("ix_invoices_org", "invoices", ["organization_id"])
221
- ensure_org_rls(op, "invoices")
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("invoices")
227
+ op.drop_table("finance_tools__invoices")
226
228
  ```
227
229
 
228
- `ensure_org_rls(op, "invoices")` enables row-level security so each
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
@@ -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 lintMigrationFile(absPath) {
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
- if (/#\s*palette:rls-ok\b/.test(src)) return issues
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
@@ -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
- const after = before.split(originalId).join(slug)
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_${slug.replace(/-/g, "_")}`
97
+ rewrittenManifest.database.schema = `app_${nextDbSafeId}`
89
98
  }
90
99
  fs.writeFileSync(manifestPath, JSON.stringify(rewrittenManifest, null, 2) + "\n")
91
100
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.29",
3
+ "version": "0.3.30",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"
@@ -5,7 +5,7 @@ from palette_sdk.db import OrgScopedTable
5
5
 
6
6
 
7
7
  class Note(OrgScopedTable):
8
- __tablename__ = "notes"
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
- "notes",
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("ix_notes_org", "notes", ["organization_id"])
20
- ensure_org_rls(op, "notes")
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("ix_notes_org", table_name="notes")
25
- op.drop_table("notes")
24
+ op.drop_index("ix_my_db_plugin__notes_org", table_name="my_db_plugin__notes")
25
+ op.drop_table("my_db_plugin__notes")