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
64 changes: 62 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.
- 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.

---

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

- **Features**
Expand Down Expand Up @@ -35,13 +79,29 @@ 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
is bounded by a configurable aggregate-rollup threshold (default 1000).
- `examples/manager/main.go` documents the canonical consumer integration.
- **Published DDL + default seed as importable artifacts.** New exported
functions `systemplane.SchemaSQL()` and `systemplane.DefaultSeedSQL()`
return, respectively, the canonical `systemplane_entries` schema DDL
(table + `systemplane_notify_v3()` function + INSERT/DELETE and UPDATE
NOTIFY triggers on the `systemplane_changes` channel) and a universal
neutral `runtime_config` default seed (`INSERT ... ON CONFLICT
(namespace, "key") DO NOTHING`). Backed by `//go:embed` of
`ddl/schema.sql` and `ddl/default_seed.sql`. This lets consumers fold
systemplane schema provisioning into their own migration pipelines
(e.g. `make systemplane-ddl` copying the artifacts into `migrations/`)
instead of relying on the lib's runtime `runSchema`. The artifacts are
static — table name `systemplane_entries` and channel `systemplane_changes`
are fixed, not parameterized. A unit test asserts the embedded schema
contains the canonical fragments the runtime emits, so a future runtime
DDL change forces the embed to be updated in lock-step.

### Changed

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.

> **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
43 changes: 43 additions & 0 deletions ddl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2025 Lerian Studio.

package systemplane

import _ "embed"

// schemaSQL is the canonical systemplane schema DDL, embedded byte-faithfully
// from ddl/schema.sql. It is the same shape the runtime emits via
// internal/postgres/postgres_schema.go and internal/manager/schema.go, with
// the table name fixed to systemplane_entries and the NOTIFY channel fixed to
// systemplane_changes.
//
//go:embed ddl/schema.sql
var schemaSQL string

// defaultSeedSQL is the universal default seed for the runtime_config
// namespace, embedded from ddl/default_seed.sql. Values are neutral baselines
// inserted with ON CONFLICT (namespace, "key") DO NOTHING.
//
//go:embed ddl/default_seed.sql
var defaultSeedSQL string

// SchemaSQL returns the full systemplane schema DDL as an importable artifact.
//
// The returned SQL creates the systemplane_entries table, the
// systemplane_notify_v3() trigger function, and the INSERT/DELETE and UPDATE
// NOTIFY triggers on the systemplane_changes channel. It is idempotent and
// safe to fold into a consumer's own migration pipeline; lib-systemplane does
// not execute it for the caller.
func SchemaSQL() string {
return schemaSQL
}

// DefaultSeedSQL returns the universal default seed INSERTs as an importable
// artifact.
//
// The returned SQL seeds neutral runtime_config defaults (log level, CORS,
// rate limit, idempotency) with ON CONFLICT (namespace, "key") DO NOTHING so
// operator-set values are never overwritten. Consumers fold this into their
// own migration pipeline; lib-systemplane does not execute it for the caller.
func DefaultSeedSQL() string {
return defaultSeedSQL
}
23 changes: 23 additions & 0 deletions ddl/default_seed.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- systemplane universal default seed — published by lib-systemplane.
--
-- A neutral baseline for the `runtime_config` namespace that becomes every
-- consumer's default. Values are intentionally NEUTRAL (no app-specific
-- tuning, no dev origins). Every row uses
-- ON CONFLICT (namespace, "key") DO NOTHING so operator-set values are never
-- overwritten on re-application.
--
-- JSONB encoding mirrors the lib's runtime seedDefaults (json.Marshal):
-- string -> '"x"'::jsonb bool -> 'true'/'false'::jsonb int -> '5'::jsonb

INSERT INTO systemplane_entries (namespace, "key", value, updated_at, updated_by)
VALUES
('runtime_config', 'app.log_level', '"info"'::jsonb, now(), 'systemplane.manager'),
('runtime_config', 'cors.allowed_origins', '""'::jsonb, now(), 'systemplane.manager'),
('runtime_config', 'cors.allowed_methods', '"GET,POST,PUT,PATCH,DELETE,OPTIONS"'::jsonb, now(), 'systemplane.manager'),
('runtime_config', 'cors.allowed_headers', '"Origin,Content-Type,Accept,Authorization"'::jsonb, now(), 'systemplane.manager'),
('runtime_config', 'rate_limit.enabled', 'false'::jsonb, now(), 'systemplane.manager'),
('runtime_config', 'rate_limit.max', '100'::jsonb, now(), 'systemplane.manager'),
('runtime_config', 'rate_limit.expiry_sec', '60'::jsonb, now(), 'systemplane.manager'),
('runtime_config', 'idempotency.require_redis', 'false'::jsonb, now(), 'systemplane.manager'),
('runtime_config', 'idempotency.duplicate_guard_ttl_seconds', '300'::jsonb, now(), 'systemplane.manager')
ON CONFLICT (namespace, "key") DO NOTHING;
56 changes: 56 additions & 0 deletions ddl/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
-- systemplane schema DDL — canonical, static artifact published by lib-systemplane.
--
-- This file is byte-faithful to the runtime DDL emitted by
-- internal/postgres/postgres_schema.go (runSchema) and
-- internal/manager/schema.go (runSchema), with the table name fixed to
-- `systemplane_entries` and the NOTIFY channel fixed to `systemplane_changes`
-- (the Manager's defaultChannel). The artifact is intentionally static: it
-- carries no table/channel placeholders so consumers standardize on
-- `systemplane_entries` / `systemplane_changes` when they fold this DDL into
-- their own migration pipeline.
--
-- The DDL is fully idempotent so re-application against a populated database
-- is safe.

CREATE TABLE IF NOT EXISTS systemplane_entries (
namespace TEXT NOT NULL,
"key" TEXT NOT NULL,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT NOT NULL DEFAULT '',
PRIMARY KEY (namespace, "key")
);

CREATE OR REPLACE FUNCTION systemplane_notify_v3() RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM pg_notify(TG_ARGV[0], json_build_object(
'namespace', OLD.namespace,
'key', OLD.key,
'op', 'delete'
)::text);
RETURN OLD;
ELSE
PERFORM pg_notify(TG_ARGV[0], json_build_object(
'namespace', NEW.namespace,
'key', NEW.key,
'op', 'upsert'
)::text);
RETURN NEW;
END IF;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS systemplane_notify_trigger ON systemplane_entries;

DROP TRIGGER IF EXISTS systemplane_notify_update_trigger ON systemplane_entries;

CREATE TRIGGER systemplane_notify_trigger
AFTER INSERT OR DELETE ON systemplane_entries
FOR EACH ROW EXECUTE FUNCTION systemplane_notify_v3('systemplane_changes');

CREATE TRIGGER systemplane_notify_update_trigger
AFTER UPDATE ON systemplane_entries
FOR EACH ROW
WHEN (OLD IS DISTINCT FROM NEW)
EXECUTE FUNCTION systemplane_notify_v3('systemplane_changes');
111 changes: 111 additions & 0 deletions ddl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//go:build unit

package systemplane_test

import (
"strings"
"testing"

systemplane "github.com/LerianStudio/lib-systemplane"
)

func TestSchemaSQL_NonEmpty(t *testing.T) {
t.Parallel()

if strings.TrimSpace(systemplane.SchemaSQL()) == "" {
t.Fatal("SchemaSQL() returned empty string")
}
}

func TestSchemaSQL_ContainsCanonicalStatements(t *testing.T) {
t.Parallel()

sql := systemplane.SchemaSQL()

// These fragments are the canonical DDL the runtime emits from
// internal/postgres/postgres_schema.go and internal/manager/schema.go.
// If a future runtime change alters the table/function/trigger shape,
// this test forces the embedded ddl/schema.sql to be updated in lock-step
// (the chosen drift-prevention strategy — see PR description).
wantFragments := []string{
"CREATE TABLE IF NOT EXISTS systemplane_entries (",
"namespace TEXT NOT NULL,",
`"key" TEXT NOT NULL,`,
"value JSONB NOT NULL,",
"updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),",
"updated_by TEXT NOT NULL DEFAULT '',",
`PRIMARY KEY (namespace, "key")`,
"CREATE OR REPLACE FUNCTION systemplane_notify_v3() RETURNS TRIGGER AS $$",
"PERFORM pg_notify(TG_ARGV[0], json_build_object(",
"'op', 'delete'",
"'op', 'upsert'",
"$$ LANGUAGE plpgsql",
"DROP TRIGGER IF EXISTS systemplane_notify_trigger ON systemplane_entries",
"DROP TRIGGER IF EXISTS systemplane_notify_update_trigger ON systemplane_entries",
"CREATE TRIGGER systemplane_notify_trigger",
"AFTER INSERT OR DELETE ON systemplane_entries",
"FOR EACH ROW EXECUTE FUNCTION systemplane_notify_v3('systemplane_changes')",
"CREATE TRIGGER systemplane_notify_update_trigger",
"AFTER UPDATE ON systemplane_entries",
"WHEN (OLD IS DISTINCT FROM NEW)",
"EXECUTE FUNCTION systemplane_notify_v3('systemplane_changes')",
}

for _, frag := range wantFragments {
if !strings.Contains(sql, frag) {
t.Errorf("SchemaSQL() missing canonical fragment:\n%q", frag)
}
}
}

func TestDefaultSeedSQL_NonEmpty(t *testing.T) {
t.Parallel()

if strings.TrimSpace(systemplane.DefaultSeedSQL()) == "" {
t.Fatal("DefaultSeedSQL() returned empty string")
}
}

func TestDefaultSeedSQL_ContainsExpectedStatements(t *testing.T) {
t.Parallel()

sql := systemplane.DefaultSeedSQL()

if !strings.Contains(sql, `INSERT INTO systemplane_entries (namespace, "key", value, updated_at, updated_by)`) {
t.Error("DefaultSeedSQL() missing INSERT header")
}

if !strings.Contains(sql, `ON CONFLICT (namespace, "key") DO NOTHING`) {
t.Error("DefaultSeedSQL() missing ON CONFLICT clause")
}

wantKeys := []struct {
key string
value string
}{
{"app.log_level", `'"info"'::jsonb`},
{"cors.allowed_origins", `'""'::jsonb`},
{"cors.allowed_methods", `'"GET,POST,PUT,PATCH,DELETE,OPTIONS"'::jsonb`},
{"cors.allowed_headers", `'"Origin,Content-Type,Accept,Authorization"'::jsonb`},
{"rate_limit.enabled", `'false'::jsonb`},
{"rate_limit.max", `'100'::jsonb`},
{"rate_limit.expiry_sec", `'60'::jsonb`},
{"idempotency.require_redis", `'false'::jsonb`},
{"idempotency.duplicate_guard_ttl_seconds", `'300'::jsonb`},
}

for _, want := range wantKeys {
if !strings.Contains(sql, want.key) {
t.Errorf("DefaultSeedSQL() missing key %q", want.key)
}

if !strings.Contains(sql, want.value) {
t.Errorf("DefaultSeedSQL() missing encoded value %q for key %q", want.value, want.key)
}
}

// Every seeded row must live in the universal runtime_config namespace.
if !strings.Contains(sql, "'runtime_config'") {
t.Error("DefaultSeedSQL() missing runtime_config namespace")
}
}
Loading
Loading