@owlmeans/marketing-consent-postgres 0.1.18-rc.0

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 ADDED
@@ -0,0 +1,15 @@
1
+ <!-- owlmeans:agent-guidance:start -->
2
+ ## Agent guidance
3
+
4
+ This package ships embedded agent skills under `agent-meta/`. After installing your
5
+ `@owlmeans/*` packages, run the OwlMeans agent-skills installer to place them into
6
+ your project's skill store (`.agents/skills/`):
7
+
8
+ ```sh
9
+ npx @owlmeans/agent-skills@^0.1.18-rc.33
10
+ ```
11
+
12
+ The embedded files are version-matched to this package release. Do not edit them
13
+ directly — they are regenerated on each publish. To contribute guidance edits,
14
+ open a PR against the source monorepo.
15
+ <!-- owlmeans:agent-guidance:end -->
@@ -0,0 +1,16 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "package": "@owlmeans/marketing-consent-postgres",
4
+ "version": "0.1.18-rc.0",
5
+ "generatedAt": "2026-09-22T17:37:10.922Z",
6
+ "canonicalRepo": "https://github.com/owlmeans/common",
7
+ "entries": [
8
+ {
9
+ "kind": "skill",
10
+ "name": "marketing-consent-postgres",
11
+ "category": "package-specific",
12
+ "file": "skills/marketing-consent-postgres/SKILL.md",
13
+ "canonicalPath": ".agents/skills/marketing-consent-postgres/SKILL.md"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,83 @@
1
+ ---
2
+ name: marketing-consent-postgres
3
+ description: How to use @owlmeans/marketing-consent-postgres — the Postgres extension that registers the two @owlmeans/server-marketing-consent resources (marketing-consent-state, marketing-consent-log) for a GENERATED TARGET PROJECT. Auto-invoked when wiring marketing-consent storage into a target project's Postgres backend, or touching the state/log table shape.
4
+ user-invocable: false
5
+ ---
6
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
7
+
8
+ # @owlmeans/marketing-consent-postgres
9
+
10
+ **Layer:** Infra extension
11
+ **Install:** `"@owlmeans/marketing-consent-postgres": "^0.1.18-rc.0"` in `dependencies` (peers `pg`, `ajv`)
12
+
13
+ The Postgres counterpart to `@owlmeans/server-marketing-consent`'s two resources. This package is
14
+ for a **generated target project**, which runs Postgres — the platform's own equivalent for
15
+ projects that stayed on Mongo is `@owlmeans/marketing-consent-mongo` (a separate, not-yet-built
16
+ package); Viable itself runs Mongo and never needs this one.
17
+
18
+ ## Key Exports
19
+
20
+ | Export | Description |
21
+ |--------|-------------|
22
+ | `makeMarketingConsentStatePostgres(dbAlias?, serviceAlias?)` | `PostgresResource<MarketingConsentStateRecord>` at `RES_MARKETING_CONSENT_STATE` |
23
+ | `makeMarketingConsentLogPostgres(dbAlias?, serviceAlias?)` | `PostgresResource<MarketingConsentLogRecord>` at `RES_MARKETING_CONSENT_LOG` |
24
+ | `appendMarketingConsentPostgres(ctx, opts?)` | Registers both, each guarded by `ctx.hasResource(...)` — safe to call more than once |
25
+
26
+ ## The schema is imported, never redeclared
27
+
28
+ Both makers set `resource.schema` to `MarketingConsentStateSchema` / `MarketingConsentLogSchema`
29
+ from `@owlmeans/server-marketing-consent` — this package owns no AJV schema of its own. The two
30
+ record shapes are shared with the (not yet built) `@owlmeans/marketing-consent-mongo` sibling;
31
+ duplicating the schema here would let the two extensions drift apart from what
32
+ `MarketingConsentService` actually reads and writes.
33
+
34
+ ## `decisions` / `terms` / `documents` / `notices` are one jsonb column each — never split
35
+
36
+ `MarketingConsentStateRecord.decisions` is an ARRAY, never an object keyed by consent key —
37
+ `server-marketing-consent`'s own skill explains why: a dotted key like `"marketing.email"` is read
38
+ as a PATH by Postgres jsonb operators, so an object-keyed shape breaks the moment a second key is
39
+ added. This package does nothing to make that safe by itself — it relies on
40
+ `@owlmeans/postgres-resource`'s **default** schema-to-table rule: any `array` of objects (or any
41
+ nested `object` property) compiles to a single opaque `jsonb` column, never a child table and never
42
+ one column per field. No `pg:` override is applied here, and none is needed — `tests/schema.spec.ts`
43
+ asserts this directly against `schemaToTableSpec`, which is the one test that would catch a
44
+ regression here (in this package, in `postgres-resource`'s compiler, or in a future schema edit
45
+ that accidentally reshapes `decisions` into something path-addressable).
46
+
47
+ ## Indexes
48
+
49
+ - `idx_mc_state_subject` — `{ columns: ['subject'], unique: true }`: one state row per subject,
50
+ the same invariant `subjectKey(subject)` being the record's own `id` already gives on the Mongo
51
+ side.
52
+ - `idx_mc_state_user` — `{ columns: ['userId'] }`: lookups across a user's profiles/entities.
53
+ - `idx_mc_log_subject_key` — `{ columns: ['subject', 'key', 'decidedAt'] }`: a subject's history
54
+ for one consent key, newest first.
55
+ - `idx_mc_log_user` — `{ columns: ['userId', 'decidedAt'] }`: a user's whole consent/terms history.
56
+
57
+ ## Wiring
58
+
59
+ ```ts
60
+ import { appendMarketingConsentPostgres } from '@owlmeans/marketing-consent-postgres'
61
+ import { appendMarketingConsentService } from '@owlmeans/server-marketing-consent'
62
+
63
+ appendMarketingConsentPostgres(context) // register the two Postgres resources first
64
+ appendMarketingConsentService(context) // the service resolves them by alias, lazily, per call
65
+ ```
66
+
67
+ Registration order relative to the service append only matters for the resources needing to exist
68
+ by the first `status`/`save`/`terms` call — `server-marketing-consent`'s own skill covers this.
69
+
70
+ ## Testing
71
+
72
+ `bun test ./tests` — `tests/schema.spec.ts` needs no live database (the jsonb-shape assertion
73
+ above); `tests/resource.spec.ts` is env-gated on `POSTGRES_URL` exactly like
74
+ `@owlmeans/postgres-resource`'s own integration suites (`@owlmeans/test-integration`'s
75
+ `postgresGate()`), and skips cleanly with no server reachable.
76
+
77
+ ## Related
78
+
79
+ - `@owlmeans/server-marketing-consent` — the service, the record types, the AJV schemas this
80
+ package imports (read its skill first)
81
+ - `@owlmeans/postgres-resource` — the resource factory, the schema-to-table compiler, `PgIndexSpec`
82
+ - `@owlmeans/marketing-consent-mongo` — the Mongo counterpart for the platform's own database (not
83
+ yet built)
@@ -0,0 +1,12 @@
1
+ import type { BasicContext } from '@owlmeans/context';
2
+ export interface MarketingConsentPostgresOptions {
3
+ dbAlias?: string;
4
+ serviceAlias?: string;
5
+ }
6
+ /**
7
+ * Registers the two Postgres resources this package owns, once. A context that already has
8
+ * either resource registered — a repeated call from an app that composes contexts in more than
9
+ * one place — is left alone, which is also what keeps this idempotent across a hot reload.
10
+ */
11
+ export declare const appendMarketingConsentPostgres: (ctx: BasicContext<any>, opts?: MarketingConsentPostgresOptions) => void;
12
+ //# sourceMappingURL=helper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helper.d.ts","sourceRoot":"","sources":["../src/helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAKrD,MAAM,WAAW,+BAA+B;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,QACpC,YAAY,CAAC,GAAG,CAAC,SAAS,+BAA+B,KAC7D,IAOF,CAAA"}
@@ -0,0 +1,16 @@
1
+ import { RES_MARKETING_CONSENT_LOG, RES_MARKETING_CONSENT_STATE } from '@owlmeans/server-marketing-consent';
2
+ import { makeMarketingConsentLogPostgres, makeMarketingConsentStatePostgres } from './resource.js';
3
+ /**
4
+ * Registers the two Postgres resources this package owns, once. A context that already has
5
+ * either resource registered — a repeated call from an app that composes contexts in more than
6
+ * one place — is left alone, which is also what keeps this idempotent across a hot reload.
7
+ */
8
+ export const appendMarketingConsentPostgres = (ctx, opts) => {
9
+ if (!ctx.hasResource(RES_MARKETING_CONSENT_STATE)) {
10
+ ctx.registerResource(makeMarketingConsentStatePostgres(opts?.dbAlias, opts?.serviceAlias));
11
+ }
12
+ if (!ctx.hasResource(RES_MARKETING_CONSENT_LOG)) {
13
+ ctx.registerResource(makeMarketingConsentLogPostgres(opts?.dbAlias, opts?.serviceAlias));
14
+ }
15
+ };
16
+ //# sourceMappingURL=helper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helper.js","sourceRoot":"","sources":["../src/helper.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,yBAAyB,EAAE,2BAA2B,EAAE,MAAM,oCAAoC,CAAA;AAE3G,OAAO,EAAE,+BAA+B,EAAE,iCAAiC,EAAE,MAAM,eAAe,CAAA;AAOlG;;;;GAIG;AACH,MAAM,CAAC,MAAM,8BAA8B,GAAG,CAC5C,GAAsB,EAAE,IAAsC,EACxD,EAAE;IACR,IAAI,CAAC,GAAG,CAAC,WAAW,CAAC,2BAA2B,CAAC,EAAE,CAAC;QAClD,GAAG,CAAC,gBAAgB,CAAC,iCAAiC,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC,CAAA;IAC5F,CAAC;IACD,IAAI,CAAC,GAAG,CAAC,WAAW,CAAC,yBAAyB,CAAC,EAAE,CAAC;QAChD,GAAG,CAAC,gBAAgB,CAAC,+BAA+B,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC,CAAA;IAC1F,CAAC;AACH,CAAC,CAAA"}
@@ -0,0 +1,3 @@
1
+ export * from './resource.js';
2
+ export * from './helper.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,eAAe,CAAA;AAC7B,cAAc,aAAa,CAAA"}
package/build/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from './resource.js';
2
+ export * from './helper.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,eAAe,CAAA;AAC7B,cAAc,aAAa,CAAA"}
@@ -0,0 +1,19 @@
1
+ import type { PostgresResource } from '@owlmeans/postgres-resource';
2
+ import type { MarketingConsentLogRecord, MarketingConsentStateRecord } from '@owlmeans/server-marketing-consent';
3
+ /**
4
+ * One row per subject. `decisions`/`terms` compile to a single `jsonb` column each — the AJV
5
+ * schema declares them as a nested array/object with no `pg:` override, and
6
+ * `schemaToTableSpec`'s default rule for "any array of objects, or a nested object" is exactly
7
+ * that: one opaque `jsonb` column, never a child table or one column per field. That is load
8
+ * bearing here, not incidental — `decisions` holds dotted keys such as `"marketing.email"`, and
9
+ * flattening it into columns or a keyed jsonb path would make that key readable as a PATH by
10
+ * Postgres' own jsonb operators (see `@owlmeans/server-marketing-consent`'s `model.ts`). This
11
+ * maker never reshapes the record; it only imports the schema and adds the indexes below.
12
+ */
13
+ export declare const makeMarketingConsentStatePostgres: (dbAlias?: string, serviceAlias?: string) => PostgresResource<MarketingConsentStateRecord>;
14
+ /**
15
+ * Append-only evidence log — same jsonb-column reasoning as the state table above applies to
16
+ * its `documents`/`notices` properties.
17
+ */
18
+ export declare const makeMarketingConsentLogPostgres: (dbAlias?: string, serviceAlias?: string) => PostgresResource<MarketingConsentLogRecord>;
19
+ //# sourceMappingURL=resource.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resource.d.ts","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAA;AAKnE,OAAO,KAAK,EAAE,yBAAyB,EAAE,2BAA2B,EAAE,MAAM,oCAAoC,CAAA;AAEhH;;;;;;;;;GASG;AACH,eAAO,MAAM,iCAAiC,aAClC,MAAM,iBAAiB,MAAM,KACtC,gBAAgB,CAAC,2BAA2B,CAS9C,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,+BAA+B,aAChC,MAAM,iBAAiB,MAAM,KACtC,gBAAgB,CAAC,yBAAyB,CAS5C,CAAA"}
@@ -0,0 +1,31 @@
1
+ import { makePostgresResource } from '@owlmeans/postgres-resource';
2
+ import { MarketingConsentLogSchema, MarketingConsentStateSchema, RES_MARKETING_CONSENT_LOG, RES_MARKETING_CONSENT_STATE } from '@owlmeans/server-marketing-consent';
3
+ /**
4
+ * One row per subject. `decisions`/`terms` compile to a single `jsonb` column each — the AJV
5
+ * schema declares them as a nested array/object with no `pg:` override, and
6
+ * `schemaToTableSpec`'s default rule for "any array of objects, or a nested object" is exactly
7
+ * that: one opaque `jsonb` column, never a child table or one column per field. That is load
8
+ * bearing here, not incidental — `decisions` holds dotted keys such as `"marketing.email"`, and
9
+ * flattening it into columns or a keyed jsonb path would make that key readable as a PATH by
10
+ * Postgres' own jsonb operators (see `@owlmeans/server-marketing-consent`'s `model.ts`). This
11
+ * maker never reshapes the record; it only imports the schema and adds the indexes below.
12
+ */
13
+ export const makeMarketingConsentStatePostgres = (dbAlias, serviceAlias) => {
14
+ const resource = makePostgresResource(RES_MARKETING_CONSENT_STATE, dbAlias, serviceAlias);
15
+ resource.schema = MarketingConsentStateSchema;
16
+ resource.index('idx_mc_state_subject', { columns: ['subject'], unique: true });
17
+ resource.index('idx_mc_state_user', { columns: ['userId'] });
18
+ return resource;
19
+ };
20
+ /**
21
+ * Append-only evidence log — same jsonb-column reasoning as the state table above applies to
22
+ * its `documents`/`notices` properties.
23
+ */
24
+ export const makeMarketingConsentLogPostgres = (dbAlias, serviceAlias) => {
25
+ const resource = makePostgresResource(RES_MARKETING_CONSENT_LOG, dbAlias, serviceAlias);
26
+ resource.schema = MarketingConsentLogSchema;
27
+ resource.index('idx_mc_log_subject_key', { columns: ['subject', 'key', 'decidedAt'] });
28
+ resource.index('idx_mc_log_user', { columns: ['userId', 'decidedAt'] });
29
+ return resource;
30
+ };
31
+ //# sourceMappingURL=resource.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resource.js","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAA;AAElE,OAAO,EACL,yBAAyB,EAAE,2BAA2B,EACtD,yBAAyB,EAAE,2BAA2B,EACvD,MAAM,oCAAoC,CAAA;AAG3C;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,iCAAiC,GAAG,CAC/C,OAAgB,EAAE,YAAqB,EACQ,EAAE;IACjD,MAAM,QAAQ,GAAG,oBAAoB,CACnC,2BAA2B,EAAE,OAAO,EAAE,YAAY,CACnD,CAAA;IACD,QAAQ,CAAC,MAAM,GAAG,2BAA2B,CAAA;IAC7C,QAAQ,CAAC,KAAK,CAAC,sBAAsB,EAAE,EAAE,OAAO,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;IAC9E,QAAQ,CAAC,KAAK,CAAC,mBAAmB,EAAE,EAAE,OAAO,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAA;IAE5D,OAAO,QAAQ,CAAA;AACjB,CAAC,CAAA;AAED;;;GAGG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,CAC7C,OAAgB,EAAE,YAAqB,EACM,EAAE;IAC/C,MAAM,QAAQ,GAAG,oBAAoB,CACnC,yBAAyB,EAAE,OAAO,EAAE,YAAY,CACjD,CAAA;IACD,QAAQ,CAAC,MAAM,GAAG,yBAAyB,CAAA;IAC3C,QAAQ,CAAC,KAAK,CAAC,wBAAwB,EAAE,EAAE,OAAO,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,WAAW,CAAC,EAAE,CAAC,CAAA;IACtF,QAAQ,CAAC,KAAK,CAAC,iBAAiB,EAAE,EAAE,OAAO,EAAE,CAAC,QAAQ,EAAE,WAAW,CAAC,EAAE,CAAC,CAAA;IAEvE,OAAO,QAAQ,CAAA;AACjB,CAAC,CAAA"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@owlmeans/marketing-consent-postgres",
3
+ "version": "0.1.18-rc.0",
4
+ "license": "MIT",
5
+ "description": "Postgres-backed resources for @owlmeans/marketing-consent — the state and log tables of a generated target project, compiled from the shared AJV schema in @owlmeans/server-marketing-consent.",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "tsc -b",
9
+ "dev": "sleep 2 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
10
+ "watch": "tsc -b -w --preserveWatchOutput --pretty",
11
+ "test": "bun test ./tests"
12
+ },
13
+ "main": "build/index.js",
14
+ "module": "build/index.js",
15
+ "types": "build/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "import": "./build/index.js",
19
+ "require": "./build/index.js",
20
+ "default": "./build/index.js",
21
+ "module": "./build/index.js",
22
+ "types": "./build/index.d.ts"
23
+ }
24
+ },
25
+ "dependencies": {
26
+ "@owlmeans/context": "^0.1.18-rc.29",
27
+ "@owlmeans/marketing-consent": "^0.1.18-rc.0",
28
+ "@owlmeans/postgres-resource": "^0.1.18-rc.31",
29
+ "@owlmeans/resource": "^0.1.18-rc.31",
30
+ "@owlmeans/server-marketing-consent": "^0.1.18-rc.0"
31
+ },
32
+ "peerDependencies": {
33
+ "ajv": "*",
34
+ "pg": "*"
35
+ },
36
+ "devDependencies": {
37
+ "@owlmeans/dep-config": "workspace:*",
38
+ "@owlmeans/test": "^0.1.18-rc.29",
39
+ "@owlmeans/test-integration": "^0.1.18-rc.26",
40
+ "@types/bun": "^1.4.0",
41
+ "@types/pg": "^8.20.4",
42
+ "ajv": "^8.17.1",
43
+ "nodemon": "^3.1.14",
44
+ "pg": "^8.22.0",
45
+ "typescript": "^7.0.2"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }
package/src/helper.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { BasicContext } from '@owlmeans/context'
2
+ import { RES_MARKETING_CONSENT_LOG, RES_MARKETING_CONSENT_STATE } from '@owlmeans/server-marketing-consent'
3
+
4
+ import { makeMarketingConsentLogPostgres, makeMarketingConsentStatePostgres } from './resource.js'
5
+
6
+ export interface MarketingConsentPostgresOptions {
7
+ dbAlias?: string
8
+ serviceAlias?: string
9
+ }
10
+
11
+ /**
12
+ * Registers the two Postgres resources this package owns, once. A context that already has
13
+ * either resource registered — a repeated call from an app that composes contexts in more than
14
+ * one place — is left alone, which is also what keeps this idempotent across a hot reload.
15
+ */
16
+ export const appendMarketingConsentPostgres = (
17
+ ctx: BasicContext<any>, opts?: MarketingConsentPostgresOptions
18
+ ): void => {
19
+ if (!ctx.hasResource(RES_MARKETING_CONSENT_STATE)) {
20
+ ctx.registerResource(makeMarketingConsentStatePostgres(opts?.dbAlias, opts?.serviceAlias))
21
+ }
22
+ if (!ctx.hasResource(RES_MARKETING_CONSENT_LOG)) {
23
+ ctx.registerResource(makeMarketingConsentLogPostgres(opts?.dbAlias, opts?.serviceAlias))
24
+ }
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './resource.js'
2
+ export * from './helper.js'
@@ -0,0 +1,47 @@
1
+ import { makePostgresResource } from '@owlmeans/postgres-resource'
2
+ import type { PostgresResource } from '@owlmeans/postgres-resource'
3
+ import {
4
+ MarketingConsentLogSchema, MarketingConsentStateSchema,
5
+ RES_MARKETING_CONSENT_LOG, RES_MARKETING_CONSENT_STATE
6
+ } from '@owlmeans/server-marketing-consent'
7
+ import type { MarketingConsentLogRecord, MarketingConsentStateRecord } from '@owlmeans/server-marketing-consent'
8
+
9
+ /**
10
+ * One row per subject. `decisions`/`terms` compile to a single `jsonb` column each — the AJV
11
+ * schema declares them as a nested array/object with no `pg:` override, and
12
+ * `schemaToTableSpec`'s default rule for "any array of objects, or a nested object" is exactly
13
+ * that: one opaque `jsonb` column, never a child table or one column per field. That is load
14
+ * bearing here, not incidental — `decisions` holds dotted keys such as `"marketing.email"`, and
15
+ * flattening it into columns or a keyed jsonb path would make that key readable as a PATH by
16
+ * Postgres' own jsonb operators (see `@owlmeans/server-marketing-consent`'s `model.ts`). This
17
+ * maker never reshapes the record; it only imports the schema and adds the indexes below.
18
+ */
19
+ export const makeMarketingConsentStatePostgres = (
20
+ dbAlias?: string, serviceAlias?: string
21
+ ): PostgresResource<MarketingConsentStateRecord> => {
22
+ const resource = makePostgresResource<MarketingConsentStateRecord>(
23
+ RES_MARKETING_CONSENT_STATE, dbAlias, serviceAlias
24
+ )
25
+ resource.schema = MarketingConsentStateSchema
26
+ resource.index('idx_mc_state_subject', { columns: ['subject'], unique: true })
27
+ resource.index('idx_mc_state_user', { columns: ['userId'] })
28
+
29
+ return resource
30
+ }
31
+
32
+ /**
33
+ * Append-only evidence log — same jsonb-column reasoning as the state table above applies to
34
+ * its `documents`/`notices` properties.
35
+ */
36
+ export const makeMarketingConsentLogPostgres = (
37
+ dbAlias?: string, serviceAlias?: string
38
+ ): PostgresResource<MarketingConsentLogRecord> => {
39
+ const resource = makePostgresResource<MarketingConsentLogRecord>(
40
+ RES_MARKETING_CONSENT_LOG, dbAlias, serviceAlias
41
+ )
42
+ resource.schema = MarketingConsentLogSchema
43
+ resource.index('idx_mc_log_subject_key', { columns: ['subject', 'key', 'decidedAt'] })
44
+ resource.index('idx_mc_log_user', { columns: ['userId', 'decidedAt'] })
45
+
46
+ return resource
47
+ }
@@ -0,0 +1,92 @@
1
+ import { postgresGate, randomNamespace } from '@owlmeans/test-integration'
2
+ import type { IntegrationGate, PostgresEnv } from '@owlmeans/test-integration'
3
+ import { PgAutoSync, resetDeclarations } from '@owlmeans/postgres-resource'
4
+ import { config, makeServerContext } from '@owlmeans/server-context'
5
+ import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
6
+ import { Pool } from 'pg'
7
+
8
+ import { appendPostgres } from '@owlmeans/postgres'
9
+ import type { PostgresService } from '@owlmeans/postgres'
10
+ import { RES_MARKETING_CONSENT_LOG, RES_MARKETING_CONSENT_STATE } from '@owlmeans/server-marketing-consent'
11
+
12
+ import { appendMarketingConsentPostgres } from '../src/index.js'
13
+
14
+ /**
15
+ * Env-gated integration harness, the same shape `@owlmeans/postgres`'s own tests use: a real
16
+ * `ServerContext` against a throwaway schema, dropped in `teardown()`. Lives here rather than
17
+ * importing the sibling package's own `tests/context.ts`, which is test-only source that no
18
+ * package exports.
19
+ */
20
+ export const gate: IntegrationGate<PostgresEnv> = postgresGate()
21
+
22
+ const url = (): string => gate.env.POSTGRES_URL as string
23
+
24
+ export const raw = async <R>(fn: (pool: Pool) => Promise<R>): Promise<R> => {
25
+ const pool = new Pool({ connectionString: url(), max: 1 })
26
+ try {
27
+ return await fn(pool)
28
+ } finally {
29
+ await pool.end().catch(() => undefined)
30
+ }
31
+ }
32
+
33
+ export interface Booted {
34
+ context: ServerContext<ServerConfig>
35
+ pg: PostgresService
36
+ }
37
+
38
+ export interface Suite {
39
+ schema: string
40
+ boot: () => Promise<Booted>
41
+ teardown: () => Promise<void>
42
+ }
43
+
44
+ export const makeSuite = (label: string): Suite => {
45
+ const prefix = process.env.POSTGRES_TEST_DB_PREFIX ?? 'omt'
46
+ const schema = randomNamespace(`${prefix}_${label}`)
47
+ const pools: Pool[] = []
48
+
49
+ const boot = async (): Promise<Booted> => {
50
+ /** A fresh declaration per suite run — this spec simulates a freshly started process. */
51
+ resetDeclarations(RES_MARKETING_CONSENT_STATE)
52
+ resetDeclarations(RES_MARKETING_CONSENT_LOG)
53
+
54
+ const cfg: ServerConfig = config('mc-pg-test', {
55
+ dbs: [{
56
+ service: 'postgres', alias: 'postgres', host: '127.0.0.1', schema,
57
+ meta: { url: url(), max: 2, retries: 3, retryDelayMillis: 200, autoSync: PgAutoSync.Full }
58
+ }]
59
+ } as Partial<ServerConfig>)
60
+
61
+ const context = makeServerContext(cfg) as ServerContext<ServerConfig>
62
+ appendPostgres(context)
63
+ appendMarketingConsentPostgres(context)
64
+
65
+ context.configure()
66
+ const pg = context.service<PostgresService>('postgres')
67
+ try {
68
+ await context.init()
69
+ } finally {
70
+ for (const pool of Object.values(pg.clients ?? {})) {
71
+ if (!pools.includes(pool)) {
72
+ pools.push(pool)
73
+ }
74
+ }
75
+ }
76
+
77
+ return { context, pg }
78
+ }
79
+
80
+ const teardown = async (): Promise<void> => {
81
+ if (gate.skip) {
82
+ return
83
+ }
84
+ await raw(async pool => { await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`) })
85
+ .catch(error => console.error(`marketing-consent-postgres test teardown (${schema}):`, error))
86
+ while (pools.length > 0) {
87
+ await pools.pop()?.end().catch(() => undefined)
88
+ }
89
+ }
90
+
91
+ return { schema, boot, teardown }
92
+ }
@@ -0,0 +1,121 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { RecordExists } from '@owlmeans/resource'
3
+ import type { PostgresResource } from '@owlmeans/postgres-resource'
4
+ import {
5
+ RES_MARKETING_CONSENT_LOG, RES_MARKETING_CONSENT_STATE
6
+ } from '@owlmeans/server-marketing-consent'
7
+ import type {
8
+ MarketingConsentLogRecord, MarketingConsentStateRecord
9
+ } from '@owlmeans/server-marketing-consent'
10
+
11
+ import { appendMarketingConsentPostgres } from '../src/index.js'
12
+ import type { Booted } from './context.js'
13
+ import { gate, makeSuite } from './context.js'
14
+
15
+ /**
16
+ * Env-gated against a real Postgres — skips cleanly (never fails) when `POSTGRES_URL` isn't
17
+ * reachable, the same gate `@owlmeans/postgres-resource`'s own integration suites use.
18
+ */
19
+ const suite = makeSuite('resource')
20
+ const it = gate.skip ? test.skip : test
21
+
22
+ describe('@owlmeans/marketing-consent-postgres — resources against a real context', () => {
23
+ if (gate.skip) {
24
+ test.skip(gate.reason ?? 'postgres gate closed', () => {})
25
+
26
+ return
27
+ }
28
+
29
+ let booted: Booted
30
+ let state: PostgresResource<MarketingConsentStateRecord>
31
+ let log: PostgresResource<MarketingConsentLogRecord>
32
+
33
+ beforeAll(async () => {
34
+ booted = await suite.boot()
35
+ state = booted.context.resource<PostgresResource<MarketingConsentStateRecord>>(
36
+ RES_MARKETING_CONSENT_STATE
37
+ )
38
+ log = booted.context.resource<PostgresResource<MarketingConsentLogRecord>>(
39
+ RES_MARKETING_CONSENT_LOG
40
+ )
41
+ })
42
+
43
+ afterAll(async () => {
44
+ await suite.teardown()
45
+ })
46
+
47
+ it('reconciles both tables at boot without error', () => {
48
+ /** `beforeAll` already drove `context.init()` — reaching here at all is the assertion. */
49
+ expect(state.table.qualified).toBe(`"${suite.schema}"."marketing_consent_state"`)
50
+ expect(log.table.qualified).toBe(`"${suite.schema}"."marketing_consent_log"`)
51
+ })
52
+
53
+ it('stores a dotted decision key verbatim and reads it back', async () => {
54
+ const record = await state.create({
55
+ subject: 'ent-1|user-1|',
56
+ userId: 'user-1',
57
+ entityId: 'ent-1',
58
+ decisions: [
59
+ {
60
+ key: 'marketing.email', granted: true, revisedAt: '2026-01-01T00:00:00.000Z',
61
+ mode: 'opt-in', decidedAt: '2026-01-01T00:00:00.000Z', source: 'settings'
62
+ },
63
+ {
64
+ key: 'trackers.advertising', granted: false, revisedAt: '2026-01-01T00:00:00.000Z',
65
+ mode: 'opt-out', decidedAt: '2026-01-01T00:00:00.000Z', source: 'settings'
66
+ }
67
+ ],
68
+ createdAt: '2026-01-01T00:00:00.000Z',
69
+ updatedAt: '2026-01-01T00:00:00.000Z'
70
+ })
71
+
72
+ const loaded = await state.get(record.id as string)
73
+ /** Verbatim array, not folded into an object keyed by the dotted consent key. */
74
+ expect(loaded.decisions).toEqual([
75
+ {
76
+ key: 'marketing.email', granted: true, revisedAt: '2026-01-01T00:00:00.000Z',
77
+ mode: 'opt-in', decidedAt: '2026-01-01T00:00:00.000Z', source: 'settings'
78
+ },
79
+ {
80
+ key: 'trackers.advertising', granted: false, revisedAt: '2026-01-01T00:00:00.000Z',
81
+ mode: 'opt-out', decidedAt: '2026-01-01T00:00:00.000Z', source: 'settings'
82
+ }
83
+ ])
84
+ expect(Array.isArray(loaded.decisions)).toBe(true)
85
+ })
86
+
87
+ it('rejects a duplicate subject through the unique index', async () => {
88
+ await state.create({
89
+ subject: 'ent-2|user-2|', userId: 'user-2', decisions: [],
90
+ createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z'
91
+ })
92
+
93
+ await expect(state.create({
94
+ subject: 'ent-2|user-2|', userId: 'user-2', decisions: [],
95
+ createdAt: '2026-01-02T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z'
96
+ })).rejects.toThrow(RecordExists)
97
+ })
98
+
99
+ it('appends a log row untouched by state writes', async () => {
100
+ const row = await log.create({
101
+ subject: 'ent-3|user-3|', userId: 'user-3', kind: 'consent', key: 'marketing.email',
102
+ granted: true, mode: 'opt-in', decidedAt: '2026-01-01T00:00:00.000Z', source: 'settings'
103
+ })
104
+
105
+ expect(await log.get(row.id as string)).toMatchObject({ key: 'marketing.email', granted: true })
106
+ })
107
+
108
+ it('is a no-op the second time it registers on the same context', () => {
109
+ const before = booted.context.resource<PostgresResource<MarketingConsentStateRecord>>(
110
+ RES_MARKETING_CONSENT_STATE
111
+ )
112
+
113
+ appendMarketingConsentPostgres(booted.context)
114
+
115
+ const after = booted.context.resource<PostgresResource<MarketingConsentStateRecord>>(
116
+ RES_MARKETING_CONSENT_STATE
117
+ )
118
+ /** Same registered instance — the second call never re-registered a resource. */
119
+ expect(after).toBe(before)
120
+ })
121
+ })
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { getDeclaration, resetDeclarations, schemaToTableSpec } from '@owlmeans/postgres-resource'
3
+ import {
4
+ MarketingConsentLogSchema, MarketingConsentStateSchema,
5
+ RES_MARKETING_CONSENT_LOG, RES_MARKETING_CONSENT_STATE
6
+ } from '@owlmeans/server-marketing-consent'
7
+
8
+ import { makeMarketingConsentLogPostgres, makeMarketingConsentStatePostgres } from '../src/resource.js'
9
+
10
+ /**
11
+ * No live database needed — `schemaToTableSpec` is the same compiler `init()` runs against a
12
+ * real connection, so this is the one test that would have caught the CRITICAL risk this
13
+ * package exists to avoid: `decisions`/`terms` silently expanding into per-field columns or a
14
+ * child table instead of staying one opaque `jsonb` blob each.
15
+ */
16
+ describe('@owlmeans/marketing-consent-postgres — schema shape', () => {
17
+ test('the maker assigns the imported schema, not a copy', () => {
18
+ resetDeclarations(RES_MARKETING_CONSENT_STATE)
19
+ resetDeclarations(RES_MARKETING_CONSENT_LOG)
20
+
21
+ const state = makeMarketingConsentStatePostgres()
22
+ const log = makeMarketingConsentLogPostgres()
23
+
24
+ expect(state.schema).toBe(MarketingConsentStateSchema)
25
+ expect(log.schema).toBe(MarketingConsentLogSchema)
26
+ })
27
+
28
+ test('decisions and terms compile to one jsonb column each, never a child table', () => {
29
+ const spec = schemaToTableSpec(
30
+ RES_MARKETING_CONSENT_STATE, MarketingConsentStateSchema, 'app', 'marketing_consent_state', true
31
+ )
32
+
33
+ const decisions = spec.byProperty.decisions
34
+ const terms = spec.byProperty.terms
35
+
36
+ expect(decisions.jsonb).toBe(true)
37
+ expect(decisions.sqlType).toBe('jsonb')
38
+ /** Not a native Postgres array column — the whole array is one opaque jsonb value. */
39
+ expect(decisions.array).toBe(false)
40
+
41
+ expect(terms.jsonb).toBe(true)
42
+ expect(terms.sqlType).toBe('jsonb')
43
+ expect(terms.array).toBe(false)
44
+
45
+ /** Exactly the schema's own top-level properties — nothing expanded into extra columns. */
46
+ expect(spec.columns.map(column => column.property).sort()).toEqual([
47
+ 'createdAt', 'decisions', 'entityId', 'gpc', 'id', 'profileId', 'subject', 'terms',
48
+ 'updatedAt', 'userId'
49
+ ])
50
+ })
51
+
52
+ test('the log table\'s documents/notices are also single jsonb columns', () => {
53
+ const spec = schemaToTableSpec(
54
+ RES_MARKETING_CONSENT_LOG, MarketingConsentLogSchema, 'app', 'marketing_consent_log', true
55
+ )
56
+
57
+ expect(spec.byProperty.documents.jsonb).toBe(true)
58
+ expect(spec.byProperty.documents.array).toBe(false)
59
+ expect(spec.byProperty.notices.jsonb).toBe(true)
60
+ expect(spec.byProperty.notices.array).toBe(false)
61
+ })
62
+
63
+ test('the makers declare the indexes the API sketch promises', () => {
64
+ resetDeclarations(RES_MARKETING_CONSENT_STATE)
65
+ resetDeclarations(RES_MARKETING_CONSENT_LOG)
66
+ makeMarketingConsentStatePostgres()
67
+ makeMarketingConsentLogPostgres()
68
+
69
+ const stateIndexes = getDeclaration(RES_MARKETING_CONSENT_STATE).indexes
70
+ expect(stateIndexes).toContainEqual(
71
+ expect.objectContaining({ name: 'idx_mc_state_subject', columns: ['subject'], unique: true })
72
+ )
73
+ expect(stateIndexes).toContainEqual(
74
+ expect.objectContaining({ name: 'idx_mc_state_user', columns: ['userId'] })
75
+ )
76
+
77
+ const logIndexes = getDeclaration(RES_MARKETING_CONSENT_LOG).indexes
78
+ expect(logIndexes).toContainEqual(
79
+ expect.objectContaining({ name: 'idx_mc_log_subject_key', columns: ['subject', 'key', 'decidedAt'] })
80
+ )
81
+ expect(logIndexes).toContainEqual(
82
+ expect.objectContaining({ name: 'idx_mc_log_user', columns: ['userId', 'decidedAt'] })
83
+ )
84
+ })
85
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "rootDir": "./src/",
8
+ "outDir": "./build/"
9
+ },
10
+ "exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
11
+ }