Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,49 @@
# Lib-systemplane Changelog

## [Unreleased]

### Changed

- **lib-systemplane no longer creates its schema or seeds defaults at
runtime.** The Postgres store and the multi-tenant `Manager` previously ran
`CREATE TABLE` / `CREATE FUNCTION` / `CREATE TRIGGER` and an
`INSERT ... ON CONFLICT DO NOTHING` defaults seed on first use
(`Store.Start` / `OnTenantActivated`). Those runtime DDL/seed paths are
removed. Consumers provision `systemplane_entries` (plus
`systemplane_notify_v3()` and the INSERT/DELETE and UPDATE NOTIFY triggers)
and any default values externally — e.g. via their migration pipeline —
using the DDL published by `SchemaSQL()` / `DefaultSeedSQL()`. The runtime
database role needs only DML (`SELECT`/`INSERT`/`UPDATE`/`DELETE`) +
`LISTEN`; it no longer needs `CREATE` on the schema. This aligns with the
least-privilege per-tenant roles handed back by the tenant-manager (which
reject runtime DDL with `permission denied for schema ... (42501)`). No
current consumers depend on the removed runtime bootstrap, so this is a
behavior change with no expected real-world breakage.
Comment on lines +20 to +21
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove speculative consumer-impact wording.

Lines 20-21 assert adoption impact that is not verifiable from code/changelog evidence. Prefer factual release-note language only.

Based on learnings: "Keep docs factual and code-backed; avoid speculative roadmap text."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 20 - 21, Edit the CHANGELOG entry text that reads
"current consumers depend on the removed runtime bootstrap, so this is a
behavior change with no expected real-world breakage." and replace the
speculative assertion with a factual, evidence-backed statement (e.g., describe
what changed and any confirmed compatibility notes) — keep only verifiable
details about the change and remove any language about adoption impact or
expectations; ensure the revised line stays in the same changelog section and
preserves tense/formatting consistent with surrounding entries.

- Warm-load (`OnTenantActivated`) now tolerates a not-yet-provisioned table:
if `systemplane_entries` does not exist yet (SQLSTATE `42P01`) it logs at
WARN and proceeds with an empty cache instead of failing activation;
LISTEN/poll refreshes the cache once the consumer's migration creates the
table. Reads return not-found / zero-value as before.

### Removed

- Postgres store: `runSchema` and the `CREATE ...` DDL builders, the
`ensureSchema` / `schemaOnce` / `schemaErr` lazy-bootstrap machinery, and
the `Start`/`resolveDB` calls into them.
- Manager: `runSchemaAndSeed`, `runSchema`, and the runtime `seedDefaults`
defaults seed.

### Unchanged

- `SchemaSQL()` and `DefaultSeedSQL()` are intact and are now the ONLY way the
schema and defaults are expressed, for consumers to vendor into migrations.

> Recommended version: **v1.7.0** (next beta `v1.7.0-beta.1`) — minor bump
> continuing the v1.6.x line that introduced `SchemaSQL()` / `DefaultSeedSQL()`.
> Tag owned by the maintainer; not tagged here.
Comment on lines +41 to +43
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Update the recommended version to a major bump.

Line 41 recommends a minor release (v1.7.0), but this section documents a breaking runtime behavior change. This should be aligned to v2.0.0 to avoid signaling a non-breaking upgrade path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 41 - 43, Update the recommended version string
from "v1.7.0" / "v1.7.0-beta.1" to a major bump "v2.0.0" (and corresponding next
beta like "v2.0.0-beta.1") in the CHANGELOG entry that currently references
`v1.7.0` so the release line reflects a breaking runtime change and communicates
a major version upgrade.


---

## [1.5.0](https://github.com/LerianStudio/lib-systemplane/releases/tag/v1.5.0)

- **Features**
Expand Down Expand Up @@ -35,8 +79,9 @@ Contributors: @bedatty, @fredcamaral, @jeffersonrodrigues92
identical v1.4.0 behaviour.
- Schema bootstrap + defaults seed via `INSERT ... ON CONFLICT DO NOTHING`
happen at `OnTenantActivated` time. Operator-set values are never
overwritten. This removes the need for hand-rolled plugin-side migrations
that seeded systemplane defaults.
overwritten. (Superseded by the Unreleased change above: runtime schema
creation and the defaults seed were removed — provision the schema and
defaults externally via `SchemaSQL()` / `DefaultSeedSQL()`.)
- Six new OpenTelemetry metrics: `systemplane.manager.tenants_active`,
`cache_entries`, `notify_received_total`, `listen_disconnects_total`,
`warmload_latency_seconds`, `get_cache_hits_total`. Tenant-id cardinality
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ These are external module imports. Do not rewrite them to in-repo paths. Do not
The Client runs in one of two modes selected at construction:

- **Single-tenant** (default). The constructor receives a non-nil `*sql.DB` / `*mongo.Client`. Reads serve from an in-process cache; writes upsert through the store and update the cache. The backend's changefeed (LISTEN/NOTIFY on Postgres, change stream on MongoDB) drives invalidation and fires `OnChange` subscribers.
- **Multi-tenant** (opt-in via `WithMultiTenantEnabled()`). DB/client may be nil. Every read/write resolves the tenant database from `ctx` via `tmcore.GetPGContext(ctx, module)` / `tmcore.GetMBContext(ctx, module)` (`lib-commons/v5/commons/tenant-manager/core`). The lib lazily runs `CREATE TABLE IF NOT EXISTS` (Postgres) / index/collection bootstrap (MongoDB) once per resolved tenant database via a `sync.Map`-backed `sync.Once` cache. No in-process cache. No LISTEN/NOTIFY. `OnChange` returns `ErrNotSupportedInMultiTenant`. Callers wire `tenant-manager/middleware.TenantMiddleware` (with `WithPG(...)` or `WithMB(...)` and a matching module name) before the lib's handlers.
- **Multi-tenant** (opt-in via `WithMultiTenantEnabled()`). DB/client may be nil. Every read/write resolves the tenant database from `ctx` via `tmcore.GetPGContext(ctx, module)` / `tmcore.GetMBContext(ctx, module)` (`lib-commons/v5/commons/tenant-manager/core`). For Postgres the lib performs NO runtime schema provisioning — `systemplane_entries` plus its NOTIFY trigger function/triggers must be created externally via `SchemaSQL()` / `DefaultSeedSQL()` (e.g. the consumer's migration pipeline); the runtime role only needs DML + `LISTEN`. (MongoDB still bootstraps its collection/indexes lazily once per resolved tenant database via a `sync.Map`-backed `sync.Once` cache.) No in-process cache. No LISTEN/NOTIFY. `OnChange` returns `ErrNotSupportedInMultiTenant`. Callers wire `tenant-manager/middleware.TenantMiddleware` (with `WithPG(...)` or `WithMB(...)` and a matching module name) before the lib's handlers.

### Storage shape

Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,28 @@ The library supports two modes; pick at construction time:
| Single-tenant | `db *sql.DB` / `*mongo.Client` | In-process cache | Through cache + store | LISTEN/NOTIFY (Postgres) or change stream (MongoDB) |
| Multi-tenant | May be nil | Resolved per-call via tenant-manager ctx | Same | Disabled — `OnChange` returns `ErrNotSupportedInMultiTenant` |

In multi-tenant mode the library does NOT hold an in-process cache. Every `Get` reads through the resolved tenant database. The lib expects the caller to wire `lib-commons/v5/commons/tenant-manager/middleware.TenantMiddleware` with `WithPG(pgManager, "<module>")` (Postgres) or `WithMB(mongoManager, "<module>")` (MongoDB) where `<module>` matches the lib's `WithModule(...)` option (default `"systemplane"`). The middleware populates the request context; the lib calls `tmcore.GetPGContext` / `tmcore.GetMBContext` to resolve the tenant database, lazily ensures the schema on first use per database, and runs the read/write against that handle.
In multi-tenant mode the library does NOT hold an in-process cache. Every `Get` reads through the resolved tenant database. The lib expects the caller to wire `lib-commons/v5/commons/tenant-manager/middleware.TenantMiddleware` with `WithPG(pgManager, "<module>")` (Postgres) or `WithMB(mongoManager, "<module>")` (MongoDB) where `<module>` matches the lib's `WithModule(...)` option (default `"systemplane"`). The middleware populates the request context; the lib calls `tmcore.GetPGContext` / `tmcore.GetMBContext` to resolve the tenant database and runs the read/write against that handle.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope the MT read-path statement to the non-Manager path.

Line 29 currently reads as absolute behavior for all MT usage. Please qualify it (e.g., “without a bound Manager”) so docs stay accurate for Manager-backed MT deployments.

Based on learnings: "Keep docs factual and code-backed; avoid speculative roadmap text."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 29, Update the README sentence to scope the multi-tenant
read-path statement to non-Manager deployments: change the absolute claim that
"In multi-tenant mode the library does NOT hold an in-process cache. Every `Get`
reads through the resolved tenant database." to explicitly state this applies
when there is no bound Manager (e.g., "without a bound Manager"). Reference the
existing middleware and helper symbols (`middleware.TenantMiddleware`, `WithPG`,
`WithMB`, `WithModule`, `tmcore.GetPGContext`, `tmcore.GetMBContext`) and ensure
the wording clarifies that Manager-backed MT deployments may behave differently
(i.e., may provide caching or a different read-path).


> **Provisioning (Postgres).** The library no longer creates its schema or seeds defaults at runtime. Provision `systemplane_entries` (plus the `systemplane_notify_v3()` trigger function and the NOTIFY triggers) and any defaults externally — e.g. via your migration pipeline — using the DDL published by [`SchemaSQL()` / `DefaultSeedSQL()`](#schema-provisioning). The runtime database role only needs DML (`SELECT`/`INSERT`/`UPDATE`/`DELETE`) + `LISTEN`; it does NOT need `CREATE` on the schema.

## Schema provisioning

`lib-systemplane` does not run any DDL or defaults seed at runtime (single- or multi-tenant). The Postgres schema and defaults are published as importable artifacts so consumers can fold them into their own migration pipeline:

```go
// systemplane.SchemaSQL() returns the full idempotent DDL:
// - CREATE TABLE IF NOT EXISTS systemplane_entries (...)
// - CREATE OR REPLACE FUNCTION systemplane_notify_v3() ...
// - the INSERT/DELETE and UPDATE NOTIFY triggers on the
// systemplane_changes channel
ddl := systemplane.SchemaSQL()

// systemplane.DefaultSeedSQL() returns neutral runtime_config defaults
// inserted with ON CONFLICT (namespace, "key") DO NOTHING.
seed := systemplane.DefaultSeedSQL()
```

Run both through a privileged role during provisioning (e.g. as a migration executed by your migrate-up step or by the tenant-manager during per-tenant database provisioning). At runtime the library only reads, writes values (DML), and — in single-tenant mode — runs `LISTEN`, so the runtime role can be least-privilege with no `CREATE` on the schema. If the table has not been provisioned yet when a multi-tenant `Manager` activates a tenant, warm-load logs a warning and proceeds with an empty cache rather than failing; the cache refreshes via LISTEN/poll once the migration creates the table.

## Single-tenant Quickstart — PostgreSQL

Expand Down Expand Up @@ -275,7 +296,7 @@ func run() error {
}
```

In multi-tenant mode the lib lazily runs `CREATE TABLE IF NOT EXISTS` (Postgres) or its MongoDB equivalent once per tenant database, the first time a request touches that database. Calling `OnChange` returns `ErrNotSupportedInMultiTenant`.
In multi-tenant mode the lib does NOT provision schema at runtime for Postgres — the `systemplane_entries` table and its NOTIFY triggers must be created externally (see [Schema provisioning](#schema-provisioning)) before the lib reads or writes. Calling `OnChange` returns `ErrNotSupportedInMultiTenant`.

## Admin HTTP routes

Expand Down
7 changes: 5 additions & 2 deletions examples/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,11 @@ func main() {

defer func() { _ = client.Close() }()

// 2. Register every runtime configuration key. Defaults are seeded
// per tenant by the Manager on OnTenantActivated.
// 2. Register every runtime configuration key. The Manager does NOT seed
// defaults at runtime — provision the schema and any defaults externally
// via systemplane.SchemaSQL() / DefaultSeedSQL() (e.g. your migration
// pipeline). OnTenantActivated warm-loads existing values and opens
// LISTEN; a not-yet-provisioned table is tolerated (empty cache).
if err := client.Register("ledger", "retries", 3, systemplane.WithDescription("retry count")); err != nil {
fail("Register retries", err)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/manager/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import (
// substitute a fake implementation.
type Connector interface {
// ResolveDB returns the tenant's primary database handle. Used for
// schema bootstrap, defaults seed, warm-load and NOTIFY refresh reads.
// warm-load and NOTIFY refresh reads. The schema is provisioned
// externally; the Manager never issues DDL through this handle.
ResolveDB(ctx context.Context, tenantID string) (dbresolver.DB, error)

// ResolveDSN returns the connection string used by pgx.Connect to open
Expand Down
21 changes: 7 additions & 14 deletions internal/manager/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import (

// OnTenantActivated bootstraps systemplane state for tenantID.
//
// Slice 3+: ensures the systemplane schema exists, seeds defaults via
// INSERT ON CONFLICT DO NOTHING, opens the LISTEN goroutine, and warm-loads
// every registered key into the cache. Slice 1 records the tenant in
// perTenant so subsequent Get calls observe an empty cache for it (which
// still falls through to the DB).
// It warm-loads every registered key into the per-tenant cache and opens the
// LISTEN goroutine. It does NOT create the schema or seed defaults — those are
// provisioned externally by the consumer's migration pipeline (see
// internal/manager/schema.go and the root package's SchemaSQL() /
// DefaultSeedSQL()). Warm-load tolerates a not-yet-provisioned table: it logs
// and proceeds with an empty cache so a provisioning race never wedges
// activation; LISTEN/poll refreshes the cache once the table exists.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
func (m *Manager) OnTenantActivated(ctx context.Context, tenantID string) error {
if m == nil || m.IsClosed() || tenantID == "" {
return nil
Expand Down Expand Up @@ -49,15 +51,6 @@ func (m *Manager) OnTenantActivated(ctx context.Context, tenantID string) error
ts := m.tenantStateFor(tenantID)
registered := m.hooks.RegisteredKeys()

if err := m.runSchemaAndSeed(ctx, db, registered); err != nil {
m.logWarn(ctx, "OnTenantActivated: schema/seed failed",
log.String("tenant_id", tenantID),
log.Err(err),
)

return err
}

if err := m.warmLoad(ctx, db, ts, registered); err != nil {
m.logWarn(ctx, "OnTenantActivated: warm-load failed",
log.String("tenant_id", tenantID),
Expand Down
26 changes: 6 additions & 20 deletions internal/manager/listen_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,7 @@ func TestListen_ReadSingle_NoRows(t *testing.T) {
db, raw, _, cleanup := freshTenantDB(t, baseDSN)
t.Cleanup(cleanup)

m := New(nil)
if err := m.runSchema(context.Background(), db); err != nil {
t.Fatalf("runSchema: %v", err)
}
provisionTestSchema(t, db)
_ = raw

v, found, err := readSingle(context.Background(), db, "ns", "missing")
Expand All @@ -210,10 +207,7 @@ func TestListen_ReadSingle_DecodeError(t *testing.T) {
db, raw, _, cleanup := freshTenantDB(t, baseDSN)
t.Cleanup(cleanup)

m := New(nil)
if err := m.runSchema(context.Background(), db); err != nil {
t.Fatalf("runSchema: %v", err)
}
provisionTestSchema(t, db)

// JSONB enforces JSON well-formedness, but our column's bytes path will
// still decode any valid JSON. To force a Unmarshal failure we have to
Expand Down Expand Up @@ -363,9 +357,7 @@ func TestListen_ApplyEvent_UpsertRowMissing_DeletesEntry(t *testing.T) {
tel, _ := newTestTelemetry(t)
m.metrics = newMetrics(tel, log.NewNop(), DefaultAggregateTenantThreshold)

if err := m.runSchema(context.Background(), db); err != nil {
t.Fatalf("runSchema: %v", err)
}
provisionTestSchema(t, db)

fc := newFakeConnectorInternal()
fc.setTenant("t", "", db)
Expand Down Expand Up @@ -400,9 +392,7 @@ func TestListen_ApplyEvent_UpsertSuccess_UpdatesCacheAndDispatches(t *testing.T)
tel, _ := newTestTelemetry(t)
m.metrics = newMetrics(tel, log.NewNop(), DefaultAggregateTenantThreshold)

if err := m.runSchema(context.Background(), db); err != nil {
t.Fatalf("runSchema: %v", err)
}
provisionTestSchema(t, db)

if _, err := raw.Exec(`INSERT INTO ` + defaultTable +
` (namespace, key, value, updated_by) VALUES ('a', 'k', '"fresh"'::jsonb, 'op')`); err != nil {
Expand Down Expand Up @@ -448,9 +438,7 @@ func TestListen_ConsumeAndReconnect_RealNotify(t *testing.T) {
tel, _ := newTestTelemetry(t)
m.metrics = newMetrics(tel, log.NewNop(), DefaultAggregateTenantThreshold)

if err := m.runSchema(context.Background(), db); err != nil {
t.Fatalf("runSchema: %v", err)
}
provisionTestSchema(t, db)

fc := newFakeConnectorInternal()
fc.setTenant("t", tDSN, db)
Expand Down Expand Up @@ -505,9 +493,7 @@ func TestListen_Reconnect_RecoversAfterDrop(t *testing.T) {
tel, _ := newTestTelemetry(t)
m.metrics = newMetrics(tel, log.NewNop(), DefaultAggregateTenantThreshold)

if err := m.runSchema(context.Background(), db); err != nil {
t.Fatalf("runSchema: %v", err)
}
provisionTestSchema(t, db)

fc := newFakeConnectorInternal()
fc.setTenant("t", tDSN, db)
Expand Down
Loading
Loading