@palettelab/cli 0.3.31 → 0.3.33

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
@@ -249,7 +249,13 @@ def upgrade():
249
249
  ensure_org_rls(op, "my_app__invoices")
250
250
  ```
251
251
 
252
- Use `ctx.repo(Model)` for org-safe CRUD:
252
+ Use `ctx.db` for the full database feature set. It is the real async
253
+ SQLAlchemy session, scoped by Palette to the app schema and current
254
+ organization, so app code can use Core/ORM queries, joins, aggregates,
255
+ transactions, bulk operations, and raw `text()` SQL. Use migrations for schema
256
+ changes such as tables, indexes, constraints, and types.
257
+
258
+ Use `ctx.repo(Model)` when simple org-safe CRUD is enough:
253
259
 
254
260
  ```python
255
261
  from fastapi import Depends
@@ -269,12 +275,15 @@ async def create_invoice(body: InvoiceIn, ctx: PluginContext = Depends(get_plugi
269
275
 
270
276
  Backend SDK features for app-owned data:
271
277
 
272
- - `PluginContext` exposes `user_id`, `organization_id`, `plugin_id`, `permissions`, `config`, `storage`, and `ctx.db`.
278
+ - `PluginContext` exposes `user_id`, `organization_id`, `plugin_id`, `permissions`, `config`, `storage`, `ctx.db`, `ctx.redis`, and `ctx.vector`.
279
+ - `ctx.db` is the full scoped SQLAlchemy `AsyncSession` for app-owned database data.
273
280
  - `ctx.repo(Model)` gives org-safe CRUD helpers for app tables.
274
281
  - `ctx.data_rooms` gives backend access to Palette Data Rooms without importing platform internals.
275
282
  - `ctx.has_permission("...")`, `ctx.has_any_permission([...])`, and `ctx.has_all_permissions([...])` check declared permissions.
276
283
  - `ctx.config_value("key")` and `ctx.require_config("key")` read app install/config values.
277
284
  - `ctx.secret("KEY")` reads app secrets from config or environment variables.
285
+ - `ctx.redis` gives a Redis-backed, plugin/org-scoped Redis API when `"redis"` is declared in `platform_services`.
286
+ - `ctx.vector` gives a Qdrant-backed, plugin/org-scoped vector API when `"vector"` is declared in `platform_services`.
278
287
  - `LifecycleHooks` lets apps define install/update/enable/disable/uninstall hooks.
279
288
  - `OrgScopedTable` and `PluginBase` keep app data inside the plugin schema model set.
280
289
 
@@ -429,6 +438,9 @@ For developer sandboxes where manual approval should not block OS testing, run t
429
438
  `pltt sandbox --env staging` is the same hosted preview flow as
430
439
  `pltt dev --sandbox --env staging`.
431
440
 
441
+ `pltt preview --env staging` is also supported as a developer-friendly alias
442
+ for the same hosted preview flow.
443
+
432
444
  `pltt dev --platform` runs the full Docker `platform-dev` image for internal platform parity testing. App developers should not need it. It pulls `ghcr.io/palette-lab/platform-dev:latest` and mounts your plugin at `/plugins/<your-id>`.
433
445
 
434
446
  Environment variables:
@@ -453,7 +465,7 @@ Palette secrets are declared in `palette-plugin.json` and resolved through
453
465
  "STRIPE_SECRET": { "scope": "plugin", "required": true },
454
466
  "DEBUG_PROBE_URL": { "scope": "dev", "required": false }
455
467
  },
456
- "platform_services": ["llm", "kv", "storage"]
468
+ "platform_services": ["llm", "redis", "storage", "vector"]
457
469
  }
458
470
  ```
459
471
 
@@ -462,6 +474,7 @@ Commands:
462
474
  ```bash
463
475
  pltt secrets init
464
476
  pltt secrets set STRIPE_SECRET --env staging --value sk_live_...
477
+ pltt secrets rotate STRIPE_SECRET --env staging --value sk_live_...
465
478
  pltt secrets list --env staging
466
479
  pltt publish --env staging --secrets-file plugin-secrets.env
467
480
  ```
@@ -471,6 +484,31 @@ never uploaded. `plugin` secrets are encrypted by the platform and attached to
471
484
  the plugin/environment. `install` secrets are filled by the installing org.
472
485
  Frontend bundles may only receive public values such as `NEXT_PUBLIC_*`.
473
486
 
487
+ Managed platform services do not require developer Redis/Qdrant keys. Declare
488
+ them and use scoped SDK clients:
489
+
490
+ ```python
491
+ await ctx.redis.set("cart:123", {"items": [1, 2]}, ttl=3600)
492
+ cart = await ctx.redis.get("cart:123")
493
+
494
+ await ctx.vector.upsert_texts(
495
+ "products",
496
+ [{"id": "p1", "text": "Red cotton shirt", "metadata": {"category": "clothing"}}],
497
+ )
498
+ matches = await ctx.vector.search("products", query="red shirt", top_k=5)
499
+ ```
500
+
501
+ The platform rewrites Redis keys and vector filters with `plugin_id` and
502
+ `organization_id`, so create/update/delete/list operations cannot reach another
503
+ app or org's data.
504
+
505
+ For provider-level Redis features, use `ctx.redis.execute(...)`. It forwards to
506
+ Redis after rewriting key arguments into the plugin/org namespace, and blocks
507
+ server/admin commands such as `FLUSHDB`, `CONFIG`, `KEYS`, and cluster
508
+ management. For advanced Qdrant features, use `ctx.vector.client()` with
509
+ `collection_name()`, `scoped_filter()`, `merge_filter()`, `scoped_payload()`,
510
+ and `scoped_point()` so custom calls remain scoped.
511
+
474
512
  ### `pltt login`
475
513
 
476
514
  Save a Palette sandbox or production environment URL plus token in `~/.palette/config.json` with file mode `0600`. Environment variables still override the stored token when present.
@@ -3,6 +3,12 @@
3
3
  from palette_sdk.plugin_router import PluginRouter
4
4
  from palette_sdk.plugin_context import MissingSecretError, PluginContext, get_plugin_context
5
5
  from palette_sdk.data_rooms import DataRoomsClient
6
+ from palette_sdk.platform_services import (
7
+ LocalRedisService,
8
+ LocalVectorService,
9
+ PlatformServiceUnavailable,
10
+ UnavailablePlatformService,
11
+ )
6
12
  from palette_sdk.repository import OrgRepository
7
13
  from palette_sdk.lifecycle import LifecycleHooks
8
14
  from palette_sdk.tool_definition import ToolDefinition
@@ -36,6 +42,10 @@ __all__ = [
36
42
  "MissingSecretError",
37
43
  "get_plugin_context",
38
44
  "DataRoomsClient",
45
+ "LocalRedisService",
46
+ "LocalVectorService",
47
+ "PlatformServiceUnavailable",
48
+ "UnavailablePlatformService",
39
49
  "OrgRepository",
40
50
  "LifecycleHooks",
41
51
  "ToolDefinition",
@@ -62,4 +72,4 @@ __all__ = [
62
72
  "route_permission_issues",
63
73
  ]
64
74
 
65
- __version__ = "0.1.6"
75
+ __version__ = "0.1.7"
@@ -113,7 +113,7 @@ class PluginManifest(BaseModel):
113
113
  tools: list[ToolEntry] = Field(default_factory=list)
114
114
  permissions: list[str] = Field(default_factory=list)
115
115
  secrets: dict[str, SecretSpec] = Field(default_factory=dict)
116
- platform_services: list[Literal["llm", "kv", "storage"]] | dict[str, PlatformServiceSpec] = Field(default_factory=list)
116
+ platform_services: list[Literal["llm", "redis", "storage", "vector"]] | dict[str, PlatformServiceSpec] = Field(default_factory=list)
117
117
  rating: float = 0.0
118
118
  reviews: int = 0
119
119
  featured: bool = False
@@ -0,0 +1,377 @@
1
+ """Platform-managed services exposed to plugin backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import math
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+
12
+ class PlatformServiceUnavailable(RuntimeError):
13
+ """Raised when a plugin uses a platform service it did not declare."""
14
+
15
+
16
+ class UnavailablePlatformService:
17
+ def __init__(self, name: str):
18
+ self.name = name
19
+
20
+ def __getattr__(self, attr: str) -> Any:
21
+ raise PlatformServiceUnavailable(
22
+ f"ctx.{self.name} is unavailable. Declare platform_services: [\"{self.name}\"] in palette-plugin.json."
23
+ )
24
+
25
+
26
+ @dataclass
27
+ class LocalRedisService:
28
+ """In-memory Redis-style implementation used by pltt dev."""
29
+
30
+ _values: dict[str, tuple[Any, float | None]] = field(default_factory=dict)
31
+
32
+ def _expired(self, key: str) -> bool:
33
+ item = self._values.get(key)
34
+ if item is None:
35
+ return True
36
+ _, expires_at = item
37
+ if expires_at is not None and expires_at <= time.time():
38
+ self._values.pop(key, None)
39
+ return True
40
+ return False
41
+
42
+ def _expires_at(self, ttl: int | None = None) -> float | None:
43
+ return time.time() + ttl if ttl and ttl > 0 else None
44
+
45
+ async def get(self, key: str, default: Any = None) -> Any:
46
+ if self._expired(key):
47
+ return default
48
+ return self._values[key][0]
49
+
50
+ def scoped_key(self, key: str) -> str:
51
+ return key
52
+
53
+ def unscoped_key(self, key: str) -> str:
54
+ return key
55
+
56
+ async def execute(self, command: str, *args: Any, **kwargs: Any) -> Any:
57
+ method = getattr(self, command.lower(), None)
58
+ if method is None:
59
+ raise NotImplementedError(f"LocalRedisService does not emulate Redis command {command!r}")
60
+ return await method(*args, **kwargs)
61
+
62
+ async def set(self, key: str, value: Any, ttl: int | None = None, *, nx: bool = False, xx: bool = False) -> bool:
63
+ exists = not self._expired(key)
64
+ if nx and exists:
65
+ return False
66
+ if xx and not exists:
67
+ return False
68
+ self._values[key] = (value, self._expires_at(ttl))
69
+ return True
70
+
71
+ async def delete(self, *keys: str) -> int:
72
+ count = 0
73
+ for key in keys:
74
+ if not self._expired(key) and key in self._values:
75
+ count += 1
76
+ self._values.pop(key, None)
77
+ return count
78
+
79
+ async def exists(self, key: str) -> bool:
80
+ return not self._expired(key)
81
+
82
+ async def expire(self, key: str, ttl: int) -> bool:
83
+ if self._expired(key):
84
+ return False
85
+ self._values[key] = (self._values[key][0], self._expires_at(ttl))
86
+ return True
87
+
88
+ async def ttl(self, key: str) -> int:
89
+ if self._expired(key):
90
+ return -2
91
+ expires_at = self._values[key][1]
92
+ if expires_at is None:
93
+ return -1
94
+ return max(0, int(expires_at - time.time()))
95
+
96
+ async def incr(self, key: str, amount: int = 1) -> int:
97
+ value = int(await self.get(key, 0)) + amount
98
+ await self.set(key, value)
99
+ return value
100
+
101
+ async def decr(self, key: str, amount: int = 1) -> int:
102
+ return await self.incr(key, -amount)
103
+
104
+ async def scan(self, prefix: str = "", limit: int = 100) -> list[str]:
105
+ out: list[str] = []
106
+ for key in list(self._values):
107
+ if self._expired(key):
108
+ continue
109
+ if key.startswith(prefix):
110
+ out.append(key)
111
+ if len(out) >= limit:
112
+ break
113
+ return out
114
+
115
+ async def hset(self, key: str, field: str, value: Any) -> int:
116
+ data = await self.get(key, {})
117
+ if not isinstance(data, dict):
118
+ data = {}
119
+ created = 0 if field in data else 1
120
+ data[field] = value
121
+ await self.set(key, data)
122
+ return created
123
+
124
+ async def hget(self, key: str, field: str, default: Any = None) -> Any:
125
+ data = await self.get(key, {})
126
+ return data.get(field, default) if isinstance(data, dict) else default
127
+
128
+ async def hgetall(self, key: str) -> dict[str, Any]:
129
+ data = await self.get(key, {})
130
+ return dict(data) if isinstance(data, dict) else {}
131
+
132
+ async def hdel(self, key: str, *fields: str) -> int:
133
+ data = await self.get(key, {})
134
+ if not isinstance(data, dict):
135
+ return 0
136
+ count = 0
137
+ for field in fields:
138
+ if field in data:
139
+ count += 1
140
+ data.pop(field, None)
141
+ await self.set(key, data)
142
+ return count
143
+
144
+ async def lpush(self, key: str, *values: Any) -> int:
145
+ data = await self.get(key, [])
146
+ if not isinstance(data, list):
147
+ data = []
148
+ data = list(values) + data
149
+ await self.set(key, data)
150
+ return len(data)
151
+
152
+ async def rpush(self, key: str, *values: Any) -> int:
153
+ data = await self.get(key, [])
154
+ if not isinstance(data, list):
155
+ data = []
156
+ data.extend(values)
157
+ await self.set(key, data)
158
+ return len(data)
159
+
160
+ async def lpop(self, key: str, default: Any = None) -> Any:
161
+ data = await self.get(key, [])
162
+ if not isinstance(data, list) or not data:
163
+ return default
164
+ value = data.pop(0)
165
+ await self.set(key, data)
166
+ return value
167
+
168
+ async def rpop(self, key: str, default: Any = None) -> Any:
169
+ data = await self.get(key, [])
170
+ if not isinstance(data, list) or not data:
171
+ return default
172
+ value = data.pop()
173
+ await self.set(key, data)
174
+ return value
175
+
176
+ async def lrange(self, key: str, start: int = 0, stop: int = -1) -> list[Any]:
177
+ data = await self.get(key, [])
178
+ if not isinstance(data, list):
179
+ return []
180
+ end = None if stop == -1 else stop + 1
181
+ return data[start:end]
182
+
183
+ async def sadd(self, key: str, *values: Any) -> int:
184
+ data = await self.get(key, [])
185
+ if not isinstance(data, list):
186
+ data = []
187
+ count = 0
188
+ for value in values:
189
+ if value not in data:
190
+ data.append(value)
191
+ count += 1
192
+ await self.set(key, data)
193
+ return count
194
+
195
+ async def srem(self, key: str, *values: Any) -> int:
196
+ data = await self.get(key, [])
197
+ if not isinstance(data, list):
198
+ return 0
199
+ count = 0
200
+ for value in values:
201
+ if value in data:
202
+ data.remove(value)
203
+ count += 1
204
+ await self.set(key, data)
205
+ return count
206
+
207
+ async def smembers(self, key: str) -> list[Any]:
208
+ data = await self.get(key, [])
209
+ return list(data) if isinstance(data, list) else []
210
+
211
+ async def zadd(self, key: str, mapping: dict[str, float]) -> int:
212
+ data = await self.get(key, {})
213
+ if not isinstance(data, dict):
214
+ data = {}
215
+ created = 0
216
+ for member, score in mapping.items():
217
+ if member not in data:
218
+ created += 1
219
+ data[member] = float(score)
220
+ await self.set(key, data)
221
+ return created
222
+
223
+ async def zrange(self, key: str, start: int = 0, stop: int = -1, *, with_scores: bool = False) -> list[Any]:
224
+ data = await self.get(key, {})
225
+ if not isinstance(data, dict):
226
+ return []
227
+ rows = sorted(data.items(), key=lambda item: item[1])
228
+ end = None if stop == -1 else stop + 1
229
+ sliced = rows[start:end]
230
+ return sliced if with_scores else [member for member, _score in sliced]
231
+
232
+ async def zrem(self, key: str, *members: Any) -> int:
233
+ data = await self.get(key, {})
234
+ if not isinstance(data, dict):
235
+ return 0
236
+ count = 0
237
+ for member in members:
238
+ if member in data:
239
+ data.pop(member, None)
240
+ count += 1
241
+ await self.set(key, data)
242
+ return count
243
+
244
+ async def enqueue(self, key: str, value: Any) -> int:
245
+ return await self.rpush(key, value)
246
+
247
+ async def dequeue(self, key: str, default: Any = None) -> Any:
248
+ return await self.lpop(key, default)
249
+
250
+ async def lock(self, key: str, token: str, ttl: int = 30) -> bool:
251
+ return await self.set(f"lock:{key}", token, ttl=ttl, nx=True)
252
+
253
+ async def unlock(self, key: str, token: str) -> bool:
254
+ lock_key = f"lock:{key}"
255
+ if await self.get(lock_key) != token:
256
+ return False
257
+ await self.delete(lock_key)
258
+ return True
259
+
260
+
261
+ def _text_vector(text: str, dimensions: int = 64) -> list[float]:
262
+ vector = [0.0] * dimensions
263
+ for token in text.lower().split():
264
+ digest = hashlib.sha256(token.encode("utf-8")).digest()
265
+ vector[int.from_bytes(digest[:4], "big") % dimensions] += 1.0
266
+ norm = math.sqrt(sum(v * v for v in vector)) or 1.0
267
+ return [v / norm for v in vector]
268
+
269
+
270
+ def _cosine(left: list[float], right: list[float]) -> float:
271
+ size = min(len(left), len(right))
272
+ if size == 0:
273
+ return 0.0
274
+ return sum(left[i] * right[i] for i in range(size))
275
+
276
+
277
+ @dataclass
278
+ class LocalVectorService:
279
+ """Small in-memory vector store used by pltt dev."""
280
+
281
+ _items: dict[str, dict[str, dict[str, Any]]] = field(default_factory=dict)
282
+
283
+ async def upsert_vectors(self, index: str, items: list[dict[str, Any]]) -> int:
284
+ bucket = self._items.setdefault(index, {})
285
+ for item in items:
286
+ record_id = str(item["id"])
287
+ bucket[record_id] = {
288
+ "id": record_id,
289
+ "vector": item["vector"],
290
+ "text": item.get("text", ""),
291
+ "metadata": item.get("metadata") or {},
292
+ }
293
+ return len(items)
294
+
295
+ async def upsert_texts(self, index: str, items: list[dict[str, Any]]) -> int:
296
+ vector_items = [
297
+ {
298
+ **item,
299
+ "vector": _text_vector(str(item.get("text", ""))),
300
+ }
301
+ for item in items
302
+ ]
303
+ return await self.upsert_vectors(index, vector_items)
304
+
305
+ async def search(
306
+ self,
307
+ index: str,
308
+ *,
309
+ query: str | None = None,
310
+ vector: list[float] | None = None,
311
+ top_k: int = 10,
312
+ filter: dict[str, Any] | None = None,
313
+ ) -> list[dict[str, Any]]:
314
+ query_vector = vector or _text_vector(query or "")
315
+ rows = []
316
+ for item in self._items.get(index, {}).values():
317
+ metadata = item.get("metadata") or {}
318
+ if filter and any(metadata.get(k) != v for k, v in filter.items()):
319
+ continue
320
+ rows.append({
321
+ "id": item["id"],
322
+ "score": _cosine(query_vector, item["vector"]),
323
+ "text": item.get("text", ""),
324
+ "metadata": metadata,
325
+ })
326
+ rows.sort(key=lambda row: row["score"], reverse=True)
327
+ return rows[: max(1, min(top_k, 100))]
328
+
329
+ async def get(self, index: str, id: str) -> dict[str, Any] | None:
330
+ return self._items.get(index, {}).get(str(id))
331
+
332
+ async def client(self):
333
+ return self
334
+
335
+ def collection_name(self) -> str:
336
+ return "local"
337
+
338
+ def point_id(self, index: str, id: str) -> str:
339
+ return f"{index}:{id}"
340
+
341
+ def scoped_filter(self, index: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
342
+ return {"index": index, **(extra or {})}
343
+
344
+ def merge_filter(self, index: str, query_filter: Any | None = None) -> Any:
345
+ return query_filter or self.scoped_filter(index)
346
+
347
+ def scoped_payload(self, index: str, id: str, *, text: str | None = None, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
348
+ return {"index": index, "record_id": str(id), "text": text or "", "metadata": metadata or {}}
349
+
350
+ def scoped_point(
351
+ self,
352
+ index: str,
353
+ *,
354
+ id: str,
355
+ vector: list[float],
356
+ text: str | None = None,
357
+ metadata: dict[str, Any] | None = None,
358
+ ) -> dict[str, Any]:
359
+ return {"id": self.point_id(index, id), "vector": vector, "payload": self.scoped_payload(index, id, text=text, metadata=metadata)}
360
+
361
+ async def delete(self, index: str, ids: list[str]) -> int:
362
+ bucket = self._items.get(index, {})
363
+ count = 0
364
+ for record_id in ids:
365
+ if bucket.pop(str(record_id), None) is not None:
366
+ count += 1
367
+ return count
368
+
369
+ async def delete_index(self, index: str) -> int:
370
+ count = len(self._items.get(index, {}))
371
+ self._items.pop(index, None)
372
+ return count
373
+
374
+ async def stats(self, index: str | None = None) -> dict[str, Any]:
375
+ if index:
376
+ return {"index": index, "count": len(self._items.get(index, {}))}
377
+ return {"indexes": {name: len(items) for name, items in self._items.items()}}
@@ -11,6 +11,7 @@ from fastapi import Depends, Request
11
11
  from sqlalchemy.ext.asyncio import AsyncSession
12
12
 
13
13
  from palette_sdk.data_rooms import DataRoomsClient
14
+ from palette_sdk.platform_services import UnavailablePlatformService
14
15
 
15
16
 
16
17
  class MissingSecretError(KeyError):
@@ -43,6 +44,8 @@ class PluginContext:
43
44
  permissions: list[str] = field(default_factory=list)
44
45
  storage: Any = None # Platform storage service injected at runtime
45
46
  data_rooms: DataRoomsClient = field(default_factory=DataRoomsClient)
47
+ redis: Any = field(default_factory=lambda: UnavailablePlatformService("redis"))
48
+ vector: Any = field(default_factory=lambda: UnavailablePlatformService("vector"))
46
49
  config: dict[str, Any] = field(default_factory=dict)
47
50
  logger: logging.Logger = field(default_factory=lambda: logging.getLogger("palette_sdk.plugin"))
48
51
 
@@ -107,6 +110,8 @@ async def get_plugin_context(request: Request) -> PluginContext:
107
110
  permissions=getattr(state, "plugin_permissions", []),
108
111
  storage=getattr(state, "storage", None),
109
112
  data_rooms=DataRoomsClient(getattr(state, "data_rooms", None)),
113
+ redis=getattr(state, "redis", None) or UnavailablePlatformService("redis"),
114
+ vector=getattr(state, "vector", None) or UnavailablePlatformService("vector"),
110
115
  config=getattr(state, "plugin_config", {}),
111
116
  logger=getattr(state, "plugin_logger", logging.getLogger(f"palette_sdk.plugin.{getattr(state, 'plugin_id', 'unknown')}")),
112
117
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "palette-sdk"
3
- version = "0.1.6"
3
+ version = "0.1.7"
4
4
  description = "Palette Platform SDK for building backend plugins"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -112,6 +112,8 @@ Available context values:
112
112
  | `ctx.permissions` | Permissions granted from `palette-plugin.json` |
113
113
  | `ctx.storage` | Runtime storage service, when available |
114
114
  | `ctx.data_rooms` | Backend Data Room client |
115
+ | `ctx.redis` | Plugin/org-scoped Redis-style service when `platform_services` includes `redis` |
116
+ | `ctx.vector` | Plugin/org-scoped vector service when `platform_services` includes `vector` |
115
117
  | `ctx.config` | App install/config values |
116
118
  | `ctx.logger` | Runtime logger for backend code |
117
119
  | `ctx.repo(Model)` | Org-safe CRUD helper for app-owned tables |
@@ -232,7 +234,16 @@ organization can only access its own rows.
232
234
 
233
235
  ## 7. Repository CRUD
234
236
 
235
- Use `ctx.repo(Model)` instead of writing repeated org filters.
237
+ Use `ctx.db` for the full database feature set. It is the real SQLAlchemy
238
+ `AsyncSession`, scoped by Palette before your route runs, so you can use normal
239
+ SQLAlchemy Core/ORM operations inside your app schema: `select`, `insert`,
240
+ `update`, `delete`, joins, aggregates, relationships, transactions, bulk
241
+ operations, and raw `text()` SQL. The production runtime sets the app schema,
242
+ uses a low-privilege runtime role, and applies org row-level security; local dev
243
+ uses the same SDK shape against the plugin-local database.
244
+
245
+ Use `ctx.repo(Model)` when you want a short convenience helper for simple CRUD
246
+ instead of writing repeated org filters.
236
247
 
237
248
  ```python
238
249
  from fastapi import Depends, HTTPException
@@ -309,6 +320,30 @@ await ctx.repo(Model).update(id, **values)
309
320
  await ctx.repo(Model).delete(id)
310
321
  ```
311
322
 
323
+ For anything outside that helper, use `ctx.db` directly:
324
+
325
+ ```python
326
+ from sqlalchemy import select, update, func, text
327
+
328
+ total = await ctx.db.scalar(select(func.count()).select_from(Invoice))
329
+
330
+ await ctx.db.execute(
331
+ update(Invoice)
332
+ .where(Invoice.status == "draft")
333
+ .values(status="queued")
334
+ )
335
+ await ctx.db.commit()
336
+
337
+ rows = (
338
+ await ctx.db.execute(text("select id, customer_name from finance_tools__invoices"))
339
+ ).mappings().all()
340
+ ```
341
+
342
+ Create or change tables, indexes, constraints, and other schema objects through
343
+ Alembic migrations. Route code intentionally has data permissions only; it
344
+ cannot access Palette core tables, another app's schema, or another org's
345
+ RLS-protected rows.
346
+
312
347
  ## 8. Data Rooms From Python
313
348
 
314
349
  Use `ctx.data_rooms` when backend code needs to create folders, find files, or
@@ -505,6 +540,35 @@ install config, plugin-scope encrypted secrets, or local `.palette/.env.local`
505
540
  during `pltt dev`. Undeclared keys still fall back to the process environment
506
541
  for local compatibility.
507
542
 
543
+ Managed Redis and vector services are declared in the manifest:
544
+
545
+ ```json
546
+ {
547
+ "platform_services": ["redis", "vector"]
548
+ }
549
+ ```
550
+
551
+ ```python
552
+ await ctx.redis.set("cache:summary", {"ready": True}, ttl=600)
553
+ value = await ctx.redis.get("cache:summary")
554
+
555
+ await ctx.vector.upsert_texts(
556
+ "knowledge",
557
+ [{"id": "doc-1", "text": "Palette apps are isolated.", "metadata": {"type": "note"}}],
558
+ )
559
+ hits = await ctx.vector.search("knowledge", query="app isolation", top_k=3)
560
+ ```
561
+
562
+ Palette scopes every Redis key and vector operation by `plugin_id` and
563
+ `organization_id`; hosted previews also include the publish id. Plugin code
564
+ cannot read, list, update, or delete records owned by another app or org.
565
+
566
+ Advanced provider features are still available through scoped helpers:
567
+ `ctx.redis.execute(...)` forwards Redis data-plane commands after key rewriting,
568
+ while blocking server/admin commands. `ctx.vector.client()` returns the Qdrant
569
+ client for custom calls; combine it with `collection_name()`, `scoped_filter()`,
570
+ `merge_filter()`, `scoped_payload()`, and `scoped_point()` to preserve isolation.
571
+
508
572
  ## 10. Lifecycle Hooks
509
573
 
510
574
  Lifecycle hooks let an app seed defaults or clean up app-owned data when the
package/lib/cli.js CHANGED
@@ -22,6 +22,10 @@ const COMMANDS = {
22
22
  run: (args, ctx) => dev([...args, "--sandbox"], ctx),
23
23
  help: "Publish a no-Docker hosted sandbox preview",
24
24
  },
25
+ preview: {
26
+ run: (args, ctx) => dev([...args, "--sandbox"], ctx),
27
+ help: "Alias for sandbox preview publish",
28
+ },
25
29
  login: { run: login, help: "Save a Palette sandbox environment for publish/dev preview" },
26
30
  doctor: {
27
31
  run: doctor,
@@ -76,12 +80,14 @@ function printHelp() {
76
80
  console.log("\nSecrets:")
77
81
  console.log(" pltt secrets init")
78
82
  console.log(" pltt secrets set NAME --value <secret> --env staging")
83
+ console.log(" pltt secrets rotate NAME --value <secret> --env staging")
79
84
  console.log(" pltt secrets list --env staging")
80
85
  console.log("\nExamples:")
81
86
  console.log(" pltt init my-app --template database")
82
87
  console.log(" pltt login --env staging --url https://sandbox.pltt.ai --token <token>")
83
88
  console.log(" cd my-app && pltt dev")
84
89
  console.log(" pltt sandbox --env staging")
90
+ console.log(" pltt preview --env staging")
85
91
  console.log(" pltt dev --sandbox --env staging")
86
92
  console.log(" pltt package")
87
93
  console.log(" pltt publish --env staging")
@@ -50,15 +50,17 @@ function loadFileValues(cwd, file) {
50
50
 
51
51
  async function run(args, { cwd }) {
52
52
  const [subcommand, ...tail] = args
53
- const manifest = loadManifest(cwd)
54
- const declared = declaredSecrets(manifest)
55
53
 
56
54
  if (!subcommand || subcommand === "help" || subcommand === "--help") {
57
55
  console.log("Usage: pltt secrets <init|list|set|rotate> [NAME] [--env staging]")
58
- console.log(" pltt secrets set NAME --value <secret> --env staging")
59
- console.log(" pltt secrets set NAME --secrets-file plugin-secrets.env --env staging")
60
- return
61
- }
56
+ console.log(" pltt secrets set NAME --value <secret> --env staging")
57
+ console.log(" pltt secrets set NAME --secrets-file plugin-secrets.env --env staging")
58
+ console.log(" pltt secrets rotate NAME --value <secret> --env staging")
59
+ return
60
+ }
61
+
62
+ const manifest = loadManifest(cwd)
63
+ const declared = declaredSecrets(manifest)
62
64
 
63
65
  if (subcommand === "init") {
64
66
  const result = initLocalEnv(cwd, manifest)
@@ -69,7 +71,7 @@ async function run(args, { cwd }) {
69
71
  }
70
72
 
71
73
  const { own, rest } = parseOwnFlags(tail)
72
- const { flags } = parseFlags(rest)
74
+ const { flags, rest: positional } = parseFlags(rest)
73
75
  let env
74
76
  try {
75
77
  env = resolveEnvironment({ cwd, flags })
@@ -96,7 +98,7 @@ async function run(args, { cwd }) {
96
98
  process.exit(1)
97
99
  }
98
100
 
99
- const name = rest.find((item) => !item.startsWith("-"))
101
+ const name = positional[0]
100
102
  if (!name) {
101
103
  console.error("[pltt] usage: pltt secrets set NAME --value <secret> [--env staging]")
102
104
  process.exit(1)
@@ -119,11 +119,28 @@ DATABASE_ENABLED = bool(MANIFEST.get("database") or MANIFEST.get("capabilities",
119
119
  DATABASE_URL = os.environ.get("PALETTE_DEV_DATABASE_URL", "sqlite+aiosqlite:///${databasePath.replace(/\\/g, "/")}")
120
120
  DEV_SECRETS = json.loads(${JSON.stringify(JSON.stringify(devSecrets))})
121
121
 
122
+ def _service_enabled(name: str) -> bool:
123
+ services = MANIFEST.get("platform_services") or []
124
+ if isinstance(services, list):
125
+ return name in services
126
+ if isinstance(services, dict):
127
+ return name in services
128
+ return False
129
+
122
130
  if SDK_PATH:
123
131
  sys.path.insert(0, SDK_PATH)
124
132
  sys.path.insert(0, str(ROOT))
125
133
  sys.path.insert(0, str(ENTRY.parent))
126
134
 
135
+ DEV_REDIS = None
136
+ DEV_VECTOR = None
137
+ if _service_enabled("redis"):
138
+ from palette_sdk.platform_services import LocalRedisService
139
+ DEV_REDIS = LocalRedisService()
140
+ if _service_enabled("vector"):
141
+ from palette_sdk.platform_services import LocalVectorService
142
+ DEV_VECTOR = LocalVectorService()
143
+
127
144
  spec = importlib.util.spec_from_file_location("palette_local_backend", ENTRY)
128
145
  module = importlib.util.module_from_spec(spec)
129
146
  assert spec and spec.loader
@@ -162,6 +179,10 @@ class DevPluginContextMiddleware(BaseHTTPMiddleware):
162
179
  "secret_scope": "dev",
163
180
  }
164
181
  request.state.storage = None
182
+ if DEV_REDIS is not None:
183
+ request.state.redis = DEV_REDIS
184
+ if DEV_VECTOR is not None:
185
+ request.state.vector = DEV_VECTOR
165
186
  if SessionLocal is None:
166
187
  request.state.db = None
167
188
  return await call_next(request)
package/lib/manifest.js CHANGED
@@ -146,7 +146,7 @@ function validateSecrets(value, errors) {
146
146
 
147
147
  function validatePlatformServices(value, errors) {
148
148
  if (value === undefined) return
149
- const known = new Set(["llm", "kv", "storage"])
149
+ const known = new Set(["llm", "redis", "storage", "vector"])
150
150
  if (Array.isArray(value)) {
151
151
  for (const service of value) {
152
152
  if (!known.has(service)) errors.push(`platform_services contains unknown service: ${service}`)
package/lib/secrets.js CHANGED
@@ -31,7 +31,15 @@ function readDotEnvFile(filePath) {
31
31
  return parseDotEnv(fs.readFileSync(filePath, "utf8"))
32
32
  }
33
33
 
34
- function writeDotEnvFile(filePath, values, manifest) {
34
+ function formatDotEnvValue(value) {
35
+ if (value === undefined || value === null) return ""
36
+ const str = String(value)
37
+ if (!str) return ""
38
+ if (/[\s#"'\\]/.test(str)) return JSON.stringify(str)
39
+ return str
40
+ }
41
+
42
+ function writeDotEnvFile(filePath, values, manifest, secretValues = {}) {
35
43
  const lines = [
36
44
  "# Palette local developer secrets.",
37
45
  "# This file is for pltt dev only and must not be committed.",
@@ -43,7 +51,7 @@ function writeDotEnvFile(filePath, values, manifest) {
43
51
  lines.push(`# scope: ${scope || "dev"}`)
44
52
  if (meta.label) lines.push(`# label: ${meta.label}`)
45
53
  if (meta.help) lines.push(`# help: ${meta.help}`)
46
- lines.push(`${name}=`)
54
+ lines.push(`${name}=${formatDotEnvValue(secretValues[name])}`)
47
55
  lines.push("")
48
56
  }
49
57
  if (Object.keys(values).length === 0 && manifest?.id) {
@@ -118,7 +126,8 @@ function initLocalEnv(cwd, manifest, { overwrite = false } = {}) {
118
126
  const declared = declaredSecrets(manifest)
119
127
  const localPath = path.join(cwd, LOCAL_ENV_PATH)
120
128
  const examplePath = path.join(cwd, EXAMPLE_ENV_PATH)
121
- if (overwrite || !fs.existsSync(localPath)) writeDotEnvFile(localPath, declared, manifest)
129
+ const existingLocal = overwrite ? {} : readDotEnvFile(localPath)
130
+ writeDotEnvFile(localPath, declared, manifest, existingLocal)
122
131
  writeDotEnvFile(examplePath, declared, manifest)
123
132
  return { localPath, examplePath, declared }
124
133
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.31",
3
+ "version": "0.3.33",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"