@owlmeans/marketing-consent-mongo 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,23 @@
1
+ # @owlmeans/marketing-consent-mongo
2
+
3
+ Mongo storage for `@owlmeans/server-marketing-consent`'s two resources — `marketing-consent-state`
4
+ and `marketing-consent-log`. Schemas are imported from `@owlmeans/server-marketing-consent`, never
5
+ duplicated here.
6
+
7
+ See `.agents/skills/marketing-consent-mongo/SKILL.md` for usage.
8
+
9
+ <!-- owlmeans:agent-guidance:start -->
10
+ ## Agent guidance
11
+
12
+ This package ships embedded agent skills under `agent-meta/`. After installing your
13
+ `@owlmeans/*` packages, run the OwlMeans agent-skills installer to place them into
14
+ your project's skill store (`.agents/skills/`):
15
+
16
+ ```sh
17
+ npx @owlmeans/agent-skills@^0.1.18-rc.33
18
+ ```
19
+
20
+ The embedded files are version-matched to this package release. Do not edit them
21
+ directly — they are regenerated on each publish. To contribute guidance edits,
22
+ open a PR against the source monorepo.
23
+ <!-- owlmeans:agent-guidance:end -->
@@ -0,0 +1,16 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "package": "@owlmeans/marketing-consent-mongo",
4
+ "version": "0.1.18-rc.0",
5
+ "generatedAt": "2026-09-22T17:37:10.924Z",
6
+ "canonicalRepo": "https://github.com/owlmeans/common",
7
+ "entries": [
8
+ {
9
+ "kind": "skill",
10
+ "name": "marketing-consent-mongo",
11
+ "category": "package-specific",
12
+ "file": "skills/marketing-consent-mongo/SKILL.md",
13
+ "canonicalPath": ".agents/skills/marketing-consent-mongo/SKILL.md"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,100 @@
1
+ ---
2
+ name: marketing-consent-mongo
3
+ description: How to use @owlmeans/marketing-consent-mongo — Mongo storage for @owlmeans/server-marketing-consent's two resources (marketing-consent-state, marketing-consent-log). Auto-invoked when registering marketing-consent resources on a Mongo-backed platform context, or touching the two Mongo indexes/schemas this package builds.
4
+ user-invocable: false
5
+ ---
6
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
7
+
8
+ # @owlmeans/marketing-consent-mongo
9
+
10
+ **Layer:** Infra
11
+ **Install:** `"@owlmeans/marketing-consent-mongo": "^0.1.18-rc.0"` in `dependencies` (peers `mongodb`, `ajv`)
12
+
13
+ Mongo storage for `@owlmeans/server-marketing-consent`'s two resources —
14
+ `marketing-consent-state` (`RES_MARKETING_CONSENT_STATE`) and `marketing-consent-log`
15
+ (`RES_MARKETING_CONSENT_LOG`) — the same relationship `@owlmeans/server-auth-identity` has to
16
+ its own Mongo resources. Use it on the platform itself (Viable runs Mongo); a generated target
17
+ project uses the Postgres sibling, `@owlmeans/marketing-consent-postgres`, instead.
18
+
19
+ ## Key Exports
20
+
21
+ | Export | Description |
22
+ |--------|-------------|
23
+ | `appendMarketingConsentMongo(ctx, opts?)` | Registers both resources — a no-op per resource if it is already registered (`ctx.hasResource`), so a second call never throws or re-registers |
24
+ | `makeMarketingConsentStateMongo(dbAlias?, serviceAlias?)` | Mongo resource for `MarketingConsentStateRecord` |
25
+ | `makeMarketingConsentLogMongo(dbAlias?, serviceAlias?)` | Mongo resource for `MarketingConsentLogRecord` |
26
+
27
+ ```ts
28
+ import { appendMarketingConsentMongo } from '@owlmeans/marketing-consent-mongo'
29
+ import { appendMarketingConsentService } from '@owlmeans/server-marketing-consent'
30
+
31
+ appendMarketingConsentMongo(context) // register the two resources first
32
+ appendMarketingConsentService(context) // then the service that resolves them by alias
33
+ ```
34
+
35
+ ## Why the schema is imported, not duplicated
36
+
37
+ Both makers assign `resource.schema` from `MarketingConsentStateSchema`/`MarketingConsentLogSchema`
38
+ — `@owlmeans/server-marketing-consent`'s own AJV schemas — never a local redeclaration. That
39
+ package's skill already explains why `decisions` is an ARRAY and not an object keyed by consent
40
+ key (a dotted key like `marketing.email` is read as a PATH by Mongo dot-notation); this package
41
+ just stores that shape, it does not reshape it.
42
+
43
+ ## The one adjustment: `id` is dropped from the Mongo validator's `required`
44
+
45
+ `MarketingConsentStateSchema`/`MarketingConsentLogSchema` correctly list `id` in `required` — that
46
+ is the right contract for `@owlmeans/server-marketing-consent`'s own AJV validation of a
47
+ fully-formed record, where `id` is always present after `mongo-resource` demarshals `_id`. Handed
48
+ to `mongo-resource` **as-is**, though, that same `required: ['id', ...]` becomes the collection's
49
+ `$jsonSchema` validator — and a Mongo document never carries a literal `id` field, only `_id`.
50
+ Verified against a real (embedded) MongoDB while building this package: assigning the schema
51
+ unmodified makes every `create()` fail with `MongoServerError: Document failed validation`
52
+ (`missingProperties: ["id"]`), because the document `mongo-resource` actually inserts has no `id`
53
+ key for the validator to find.
54
+
55
+ `resource.ts`'s private `forMongoValidator(schema)` fixes this the same way
56
+ `@owlmeans/server-payment`'s own hand-written Mongo schemas already do it — keep `id` in
57
+ `properties` (nullable/optional there is fine) but drop it from `required` — by shallow-cloning
58
+ the schema and filtering `required`. `properties` stays the exact same object the shared schema
59
+ exports; nothing about the record shape is duplicated or reinterpreted, only the Mongo-specific
60
+ `required` list is narrowed. `tests/schema.spec.ts` asserts this precisely: `resource.schema.
61
+ properties` is reference-equal to the shared schema's `properties`, and `required` equals the
62
+ shared list minus `id`.
63
+
64
+ This is a general gap in `@owlmeans/mongo-resource`'s schema→validator conversion (`schemaToMongoSchema`
65
+ carries `required` through verbatim, with no special case for `id`), not something specific to
66
+ marketing-consent — any future package that assigns a shared "full record" AJV schema straight to
67
+ a `MongoResource.schema` will hit the same failure. This package works around it locally; it does
68
+ not fix `mongo-resource` itself.
69
+
70
+ ## The two indexes
71
+
72
+ - `idx_mc_state_subject` on `{ subject: 1 }`, **unique** — one current-state record per person
73
+ (`subject` is already `subjectKey(subject)`, so this is a collection-level backstop against a
74
+ create race, not the primary uniqueness mechanism).
75
+ - `idx_mc_state_user` on `{ userId: 1 }` — lookups by account, independent of `subject`'s
76
+ entity-scoping.
77
+ - `idx_mc_log_subject_key` on `{ subject: 1, key: 1, decidedAt: -1 }` — the log's one real read,
78
+ "the latest decision for this subject and key", served straight off the index.
79
+ - `idx_mc_log_user` on `{ userId: 1, decidedAt: -1 }` — an account's decision history in order.
80
+
81
+ ## Testing
82
+
83
+ `tests/schema.spec.ts` needs no live Mongo — the schema-wiring assertions above. `tests/
84
+ resource.spec.ts` is Category C (`/testing-integration`): gated on `MONGO_URL`, self-skips
85
+ cleanly when unreachable. It boots a real `ServerContext` through `tests/context.ts` (mirrors
86
+ `@owlmeans/mongo`'s and `@owlmeans/server-payment`'s own harness), registers both resources via
87
+ `appendMarketingConsentMongo`, and covers: a state record with a dotted key inside `decisions`
88
+ round-tripping verbatim, the unique index rejecting a second state record for the same `subject`,
89
+ a log record, and `appendMarketingConsentMongo` being a true no-op — `hasResource` true and the
90
+ same resource instances — the second time it runs.
91
+
92
+ ## Related
93
+
94
+ - `@owlmeans/server-marketing-consent` — the two record shapes/schemas, the service that resolves
95
+ these resources by alias, and why `decisions` is an array
96
+ - `@owlmeans/mongo-resource` — the maker (`makeMongoResource`), `index()`, schema→validator
97
+ conversion
98
+ - `@owlmeans/server-auth-identity` — the `appendXResources(ctx, dbAlias?)` shape this package's
99
+ `appendMarketingConsentMongo` copies
100
+ - `@owlmeans/marketing-consent-postgres` — the Postgres sibling for generated target projects
@@ -0,0 +1,14 @@
1
+ import type { BasicContext } from '@owlmeans/context';
2
+ export interface MarketingConsentMongoOptions {
3
+ dbAlias?: string;
4
+ serviceAlias?: string;
5
+ }
6
+ /**
7
+ * Registers the two Mongo resources `@owlmeans/server-marketing-consent`'s `MarketingConsentService`
8
+ * resolves by alias. No-op per resource if it is already registered, the same guard
9
+ * `@owlmeans/server-auth-identity`'s `appendAuthIdentityResources` uses for its own services — so a
10
+ * second call (an app composing several `append*` helpers that all happen to touch this alias)
11
+ * never re-registers or throws.
12
+ */
13
+ export declare const appendMarketingConsentMongo: (ctx: BasicContext<any>, opts?: MarketingConsentMongoOptions) => void;
14
+ //# 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;AAIrD,MAAM,WAAW,4BAA4B;IAC3C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,QACjC,YAAY,CAAC,GAAG,CAAC,SAAS,4BAA4B,KAC1D,IAOF,CAAA"}
@@ -0,0 +1,18 @@
1
+ import { RES_MARKETING_CONSENT_LOG, RES_MARKETING_CONSENT_STATE } from '@owlmeans/server-marketing-consent';
2
+ import { makeMarketingConsentLogMongo, makeMarketingConsentStateMongo } from './resource.js';
3
+ /**
4
+ * Registers the two Mongo resources `@owlmeans/server-marketing-consent`'s `MarketingConsentService`
5
+ * resolves by alias. No-op per resource if it is already registered, the same guard
6
+ * `@owlmeans/server-auth-identity`'s `appendAuthIdentityResources` uses for its own services — so a
7
+ * second call (an app composing several `append*` helpers that all happen to touch this alias)
8
+ * never re-registers or throws.
9
+ */
10
+ export const appendMarketingConsentMongo = (ctx, opts) => {
11
+ if (!ctx.hasResource(RES_MARKETING_CONSENT_STATE)) {
12
+ ctx.registerResource(makeMarketingConsentStateMongo(opts?.dbAlias, opts?.serviceAlias));
13
+ }
14
+ if (!ctx.hasResource(RES_MARKETING_CONSENT_LOG)) {
15
+ ctx.registerResource(makeMarketingConsentLogMongo(opts?.dbAlias, opts?.serviceAlias));
16
+ }
17
+ };
18
+ //# 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;AAC3G,OAAO,EAAE,4BAA4B,EAAE,8BAA8B,EAAE,MAAM,eAAe,CAAA;AAO5F;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,2BAA2B,GAAG,CACzC,GAAsB,EAAE,IAAmC,EACrD,EAAE;IACR,IAAI,CAAC,GAAG,CAAC,WAAW,CAAC,2BAA2B,CAAC,EAAE,CAAC;QAClD,GAAG,CAAC,gBAAgB,CAAC,8BAA8B,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC,CAAA;IACzF,CAAC;IACD,IAAI,CAAC,GAAG,CAAC,WAAW,CAAC,yBAAyB,CAAC,EAAE,CAAC;QAChD,GAAG,CAAC,gBAAgB,CAAC,4BAA4B,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC,CAAA;IACvF,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,15 @@
1
+ import type { MongoResource } from '@owlmeans/mongo-resource';
2
+ import type { MarketingConsentLogRecord, MarketingConsentStateRecord } from '@owlmeans/server-marketing-consent';
3
+ /**
4
+ * One record per subject (`id` = `subjectKey(subject)`, already unique by construction), so the
5
+ * unique index on `subject` is the collection-level backstop against a create race producing two
6
+ * current-state rows for the same person.
7
+ */
8
+ export declare const makeMarketingConsentStateMongo: (dbAlias?: string, serviceAlias?: string) => MongoResource<MarketingConsentStateRecord>;
9
+ /**
10
+ * Append-only evidence log. The compound index serves the one read this record shape exists
11
+ * for — "the latest decision for this subject and key" — sorted straight off the index rather
12
+ * than folded from a full per-subject scan.
13
+ */
14
+ export declare const makeMarketingConsentLogMongo: (dbAlias?: string, serviceAlias?: string) => MongoResource<MarketingConsentLogRecord>;
15
+ //# 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,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAK7D,OAAO,KAAK,EAAE,yBAAyB,EAAE,2BAA2B,EAAE,MAAM,oCAAoC,CAAA;AAyBhH;;;;GAIG;AACH,eAAO,MAAM,8BAA8B,aAC/B,MAAM,iBAAiB,MAAM,KACtC,aAAa,CAAC,2BAA2B,CAQ3C,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,4BAA4B,aAC7B,MAAM,iBAAiB,MAAM,KACtC,aAAa,CAAC,yBAAyB,CAQzC,CAAA"}
@@ -0,0 +1,48 @@
1
+ import { makeMongoResource } from '@owlmeans/mongo-resource';
2
+ import { MarketingConsentLogSchema, MarketingConsentStateSchema, RES_MARKETING_CONSENT_LOG, RES_MARKETING_CONSENT_STATE } from '@owlmeans/server-marketing-consent';
3
+ /**
4
+ * A stored Mongo document never carries a literal `id` field — only `_id`, which mongo-resource
5
+ * derives `id` FROM on every read and never writes on `create()`. The shared schema's `required`
6
+ * list is correct for `@owlmeans/server-marketing-consent`'s own AJV validation of a fully-formed
7
+ * record (post-demarshal, where `id` is always present) — but handed to `mongo-resource` verbatim
8
+ * as the collection's `$jsonSchema` validator, it makes EVERY `create()` fail with "Document
9
+ * failed validation" (`missingProperties: ["id"]`), because the document actually inserted never
10
+ * has that key. Verified against a real (embedded) MongoDB while building this package: assigning
11
+ * `MarketingConsentStateSchema` as-is reproduces the failure, dropping `id` from `required` fixes
12
+ * it. `properties` (and everything else) stays the exact same object `@owlmeans/
13
+ * server-marketing-consent` exports — nothing here is duplicated, only the Mongo-specific
14
+ * `required` list is narrowed, the same `id`-not-required shape `@owlmeans/server-payment`'s own
15
+ * hand-written Mongo schemas already use for their records.
16
+ */
17
+ const forMongoValidator = (schema) => {
18
+ const source = schema;
19
+ if (source.required == null) {
20
+ return schema;
21
+ }
22
+ return { ...schema, required: source.required.filter(field => field !== 'id') };
23
+ };
24
+ /**
25
+ * One record per subject (`id` = `subjectKey(subject)`, already unique by construction), so the
26
+ * unique index on `subject` is the collection-level backstop against a create race producing two
27
+ * current-state rows for the same person.
28
+ */
29
+ export const makeMarketingConsentStateMongo = (dbAlias, serviceAlias) => {
30
+ const resource = makeMongoResource(RES_MARKETING_CONSENT_STATE, dbAlias, serviceAlias);
31
+ resource.schema = forMongoValidator(MarketingConsentStateSchema);
32
+ resource.index('idx_mc_state_subject', { subject: 1 }, { unique: true });
33
+ resource.index('idx_mc_state_user', { userId: 1 });
34
+ return resource;
35
+ };
36
+ /**
37
+ * Append-only evidence log. The compound index serves the one read this record shape exists
38
+ * for — "the latest decision for this subject and key" — sorted straight off the index rather
39
+ * than folded from a full per-subject scan.
40
+ */
41
+ export const makeMarketingConsentLogMongo = (dbAlias, serviceAlias) => {
42
+ const resource = makeMongoResource(RES_MARKETING_CONSENT_LOG, dbAlias, serviceAlias);
43
+ resource.schema = forMongoValidator(MarketingConsentLogSchema);
44
+ resource.index('idx_mc_log_subject_key', { subject: 1, key: 1, decidedAt: -1 });
45
+ resource.index('idx_mc_log_user', { userId: 1, decidedAt: -1 });
46
+ return resource;
47
+ };
48
+ //# sourceMappingURL=resource.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resource.js","sourceRoot":"","sources":["../src/resource.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAA;AAE5D,OAAO,EACL,yBAAyB,EAAE,2BAA2B,EAAE,yBAAyB,EACjF,2BAA2B,EAC5B,MAAM,oCAAoC,CAAA;AAI3C;;;;;;;;;;;;;GAaG;AACH,MAAM,iBAAiB,GAAG,CAAC,MAAiB,EAAa,EAAE;IACzD,MAAM,MAAM,GAAG,MAAiC,CAAA;IAChD,IAAI,MAAM,CAAC,QAAQ,IAAI,IAAI,EAAE,CAAC;QAC5B,OAAO,MAAM,CAAA;IACf,CAAC;IACD,OAAO,EAAE,GAAI,MAAiB,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,KAAK,IAAI,CAAC,EAAe,CAAA;AAC1G,CAAC,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,8BAA8B,GAAG,CAC5C,OAAgB,EAAE,YAAqB,EACK,EAAE;IAC9C,MAAM,QAAQ,GAAG,iBAAiB,CAChC,2BAA2B,EAAE,OAAO,EAAE,YAAY,CACnD,CAAA;IACD,QAAQ,CAAC,MAAM,GAAG,iBAAiB,CAAC,2BAA2B,CAAC,CAAA;IAChE,QAAQ,CAAC,KAAK,CAAC,sBAAsB,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;IACxE,QAAQ,CAAC,KAAK,CAAC,mBAAmB,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAA;IAClD,OAAO,QAAQ,CAAA;AACjB,CAAC,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAC1C,OAAgB,EAAE,YAAqB,EACG,EAAE;IAC5C,MAAM,QAAQ,GAAG,iBAAiB,CAChC,yBAAyB,EAAE,OAAO,EAAE,YAAY,CACjD,CAAA;IACD,QAAQ,CAAC,MAAM,GAAG,iBAAiB,CAAC,yBAAyB,CAAC,CAAA;IAC9D,QAAQ,CAAC,KAAK,CAAC,wBAAwB,EAAE,EAAE,OAAO,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,EAAE,CAAC,CAAA;IAC/E,QAAQ,CAAC,KAAK,CAAC,iBAAiB,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,EAAE,CAAC,CAAA;IAC/D,OAAO,QAAQ,CAAA;AACjB,CAAC,CAAA"}
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@owlmeans/marketing-consent-mongo",
3
+ "version": "0.1.18-rc.0",
4
+ "license": "MIT",
5
+ "description": "Mongo storage for @owlmeans/server-marketing-consent's two resources (marketing-consent-state, marketing-consent-log) — schemas imported, never duplicated. For the platform; generated target projects use @owlmeans/marketing-consent-postgres.",
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/mongo-resource": "^0.1.18-rc.32",
29
+ "@owlmeans/resource": "^0.1.18-rc.31",
30
+ "@owlmeans/server-marketing-consent": "^0.1.18-rc.0"
31
+ },
32
+ "peerDependencies": {
33
+ "ajv": "*",
34
+ "mongodb": "*"
35
+ },
36
+ "devDependencies": {
37
+ "@owlmeans/dep-config": "workspace:*",
38
+ "@owlmeans/mongo": "^0.1.18-rc.31",
39
+ "@owlmeans/server-context": "^0.1.18-rc.32",
40
+ "@owlmeans/test": "^0.1.18-rc.29",
41
+ "@owlmeans/test-integration": "^0.1.18-rc.26",
42
+ "@types/bun": "^1.4.0",
43
+ "ajv": "^8.17.1",
44
+ "mongodb": "^7.5.0",
45
+ "nodemon": "^3.1.14",
46
+ "typescript": "^7.0.2"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
package/src/helper.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { BasicContext } from '@owlmeans/context'
2
+ import { RES_MARKETING_CONSENT_LOG, RES_MARKETING_CONSENT_STATE } from '@owlmeans/server-marketing-consent'
3
+ import { makeMarketingConsentLogMongo, makeMarketingConsentStateMongo } from './resource.js'
4
+
5
+ export interface MarketingConsentMongoOptions {
6
+ dbAlias?: string
7
+ serviceAlias?: string
8
+ }
9
+
10
+ /**
11
+ * Registers the two Mongo resources `@owlmeans/server-marketing-consent`'s `MarketingConsentService`
12
+ * resolves by alias. No-op per resource if it is already registered, the same guard
13
+ * `@owlmeans/server-auth-identity`'s `appendAuthIdentityResources` uses for its own services — so a
14
+ * second call (an app composing several `append*` helpers that all happen to touch this alias)
15
+ * never re-registers or throws.
16
+ */
17
+ export const appendMarketingConsentMongo = (
18
+ ctx: BasicContext<any>, opts?: MarketingConsentMongoOptions
19
+ ): void => {
20
+ if (!ctx.hasResource(RES_MARKETING_CONSENT_STATE)) {
21
+ ctx.registerResource(makeMarketingConsentStateMongo(opts?.dbAlias, opts?.serviceAlias))
22
+ }
23
+ if (!ctx.hasResource(RES_MARKETING_CONSENT_LOG)) {
24
+ ctx.registerResource(makeMarketingConsentLogMongo(opts?.dbAlias, opts?.serviceAlias))
25
+ }
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './resource.js'
2
+ export * from './helper.js'
@@ -0,0 +1,64 @@
1
+ import { makeMongoResource } from '@owlmeans/mongo-resource'
2
+ import type { MongoResource } from '@owlmeans/mongo-resource'
3
+ import {
4
+ MarketingConsentLogSchema, MarketingConsentStateSchema, RES_MARKETING_CONSENT_LOG,
5
+ RES_MARKETING_CONSENT_STATE
6
+ } from '@owlmeans/server-marketing-consent'
7
+ import type { MarketingConsentLogRecord, MarketingConsentStateRecord } from '@owlmeans/server-marketing-consent'
8
+ import type { AnySchema } from 'ajv'
9
+
10
+ /**
11
+ * A stored Mongo document never carries a literal `id` field — only `_id`, which mongo-resource
12
+ * derives `id` FROM on every read and never writes on `create()`. The shared schema's `required`
13
+ * list is correct for `@owlmeans/server-marketing-consent`'s own AJV validation of a fully-formed
14
+ * record (post-demarshal, where `id` is always present) — but handed to `mongo-resource` verbatim
15
+ * as the collection's `$jsonSchema` validator, it makes EVERY `create()` fail with "Document
16
+ * failed validation" (`missingProperties: ["id"]`), because the document actually inserted never
17
+ * has that key. Verified against a real (embedded) MongoDB while building this package: assigning
18
+ * `MarketingConsentStateSchema` as-is reproduces the failure, dropping `id` from `required` fixes
19
+ * it. `properties` (and everything else) stays the exact same object `@owlmeans/
20
+ * server-marketing-consent` exports — nothing here is duplicated, only the Mongo-specific
21
+ * `required` list is narrowed, the same `id`-not-required shape `@owlmeans/server-payment`'s own
22
+ * hand-written Mongo schemas already use for their records.
23
+ */
24
+ const forMongoValidator = (schema: AnySchema): AnySchema => {
25
+ const source = schema as { required?: string[] }
26
+ if (source.required == null) {
27
+ return schema
28
+ }
29
+ return { ...(schema as object), required: source.required.filter(field => field !== 'id') } as AnySchema
30
+ }
31
+
32
+ /**
33
+ * One record per subject (`id` = `subjectKey(subject)`, already unique by construction), so the
34
+ * unique index on `subject` is the collection-level backstop against a create race producing two
35
+ * current-state rows for the same person.
36
+ */
37
+ export const makeMarketingConsentStateMongo = (
38
+ dbAlias?: string, serviceAlias?: string
39
+ ): MongoResource<MarketingConsentStateRecord> => {
40
+ const resource = makeMongoResource<MarketingConsentStateRecord>(
41
+ RES_MARKETING_CONSENT_STATE, dbAlias, serviceAlias
42
+ )
43
+ resource.schema = forMongoValidator(MarketingConsentStateSchema)
44
+ resource.index('idx_mc_state_subject', { subject: 1 }, { unique: true })
45
+ resource.index('idx_mc_state_user', { userId: 1 })
46
+ return resource
47
+ }
48
+
49
+ /**
50
+ * Append-only evidence log. The compound index serves the one read this record shape exists
51
+ * for — "the latest decision for this subject and key" — sorted straight off the index rather
52
+ * than folded from a full per-subject scan.
53
+ */
54
+ export const makeMarketingConsentLogMongo = (
55
+ dbAlias?: string, serviceAlias?: string
56
+ ): MongoResource<MarketingConsentLogRecord> => {
57
+ const resource = makeMongoResource<MarketingConsentLogRecord>(
58
+ RES_MARKETING_CONSENT_LOG, dbAlias, serviceAlias
59
+ )
60
+ resource.schema = forMongoValidator(MarketingConsentLogSchema)
61
+ resource.index('idx_mc_log_subject_key', { subject: 1, key: 1, decidedAt: -1 })
62
+ resource.index('idx_mc_log_user', { userId: 1, decidedAt: -1 })
63
+ return resource
64
+ }
@@ -0,0 +1,94 @@
1
+ import { mongoGate, randomNamespace } from '@owlmeans/test-integration'
2
+ import type { IntegrationGate, MongoEnv } from '@owlmeans/test-integration'
3
+ import { appendMongo } from '@owlmeans/mongo'
4
+ import type { MongoDbService } from '@owlmeans/mongo-resource'
5
+ import { config, makeServerContext } from '@owlmeans/server-context'
6
+ import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
7
+ import { MongoClient } from 'mongodb'
8
+ import { appendMarketingConsentMongo } from '../src/helper.js'
9
+
10
+ /**
11
+ * Category-C harness: a real `ServerContext` over a real Mongo database, with the two resources
12
+ * registered through `appendMarketingConsentMongo` exactly as a consuming app would — mirrors
13
+ * `@owlmeans/mongo`'s and `@owlmeans/server-payment`'s own `tests/context.ts`. One database per
14
+ * suite, dropped by that suite's own teardown.
15
+ */
16
+ export const gate: IntegrationGate<MongoEnv> = mongoGate()
17
+
18
+ const url = (): string => gate.env.MONGO_URL as string
19
+
20
+ /** Short lived client for setup and assertions outside any context. */
21
+ export const raw = async <R>(fn: (client: MongoClient) => Promise<R>): Promise<R> => {
22
+ const client = new MongoClient(url())
23
+ try {
24
+ await client.connect()
25
+
26
+ return await fn(client)
27
+ } finally {
28
+ await client.close().catch(() => undefined)
29
+ }
30
+ }
31
+
32
+ export interface Booted {
33
+ context: ServerContext<ServerConfig>
34
+ mongo: MongoDbService
35
+ }
36
+
37
+ export interface MongoSuite {
38
+ database: string
39
+ boot: () => Promise<Booted>
40
+ teardown: () => Promise<void>
41
+ }
42
+
43
+ export const makeSuite = (label: string): MongoSuite => {
44
+ const prefix = process.env.MONGO_TEST_DB_PREFIX ?? 'omt'
45
+ const database = randomNamespace(`${prefix}_${label}`)
46
+ const clients: MongoClient[] = []
47
+
48
+ const boot = async (): Promise<Booted> => {
49
+ const parsed = new URL(url())
50
+ const cfg: ServerConfig = config('mc-mongo-test', {
51
+ dbs: [{
52
+ service: 'mongo',
53
+ alias: 'mongo',
54
+ host: decodeURIComponent(parsed.hostname).replace(/^\[|\]$/g, ''),
55
+ port: parsed.port !== '' ? parseInt(parsed.port, 10) : 27017,
56
+ schema: database,
57
+ ...(parsed.username !== ''
58
+ ? { user: decodeURIComponent(parsed.username), secret: decodeURIComponent(parsed.password) }
59
+ : {})
60
+ }]
61
+ } as Partial<ServerConfig>)
62
+
63
+ const context = makeServerContext(cfg) as ServerContext<ServerConfig>
64
+ appendMongo(context)
65
+ appendMarketingConsentMongo(context)
66
+
67
+ context.configure()
68
+ const mongo = context.service<MongoDbService>('mongo')
69
+ try {
70
+ await context.init()
71
+ } finally {
72
+ for (const client of Object.values(mongo.clients ?? {}) as MongoClient[]) {
73
+ if (!clients.includes(client)) {
74
+ clients.push(client)
75
+ }
76
+ }
77
+ }
78
+
79
+ return { context, mongo }
80
+ }
81
+
82
+ const teardown = async (): Promise<void> => {
83
+ if (gate.skip) {
84
+ return
85
+ }
86
+ await raw(async client => { await client.db(database).dropDatabase() })
87
+ .catch(error => console.error(`marketing-consent-mongo test teardown (${database}):`, error))
88
+ while (clients.length > 0) {
89
+ await clients.pop()?.close().catch(() => undefined)
90
+ }
91
+ }
92
+
93
+ return { database, boot, teardown }
94
+ }
@@ -0,0 +1,100 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import type { MongoResource } from '@owlmeans/mongo-resource'
3
+ import type { MarketingConsentLogRecord, MarketingConsentStateRecord } from '@owlmeans/server-marketing-consent'
4
+ import { RES_MARKETING_CONSENT_LOG, RES_MARKETING_CONSENT_STATE } from '@owlmeans/server-marketing-consent'
5
+ import { appendMarketingConsentMongo } from '../src/helper.js'
6
+ import { gate, makeSuite } from './context.js'
7
+
8
+ const suite = makeSuite('mc-resource')
9
+ const it = gate.skip ? test.skip : test
10
+
11
+ const decision = (key: string) => ({
12
+ key, granted: true, revisedAt: '2026-01-01T00:00:00.000Z', mode: 'opt-in' as const,
13
+ decidedAt: '2026-01-01T00:00:00.000Z', source: 'settings' as const,
14
+ })
15
+
16
+ describe('@owlmeans/marketing-consent-mongo — resources against a real Mongo', () => {
17
+ if (gate.skip) {
18
+ test.skip(gate.reason ?? 'mongo gate closed', () => {})
19
+
20
+ return
21
+ }
22
+
23
+ afterAll(async () => {
24
+ await suite.teardown()
25
+ })
26
+
27
+ it('stores a state record with a dotted consent key and reads it back verbatim', async () => {
28
+ const { context } = await suite.boot()
29
+ const state = context.resource<MongoResource<MarketingConsentStateRecord>>(RES_MARKETING_CONSENT_STATE)
30
+
31
+ const now = new Date().toISOString()
32
+ const created = await state.create({
33
+ subject: 'subject-1',
34
+ userId: 'user-1',
35
+ // A dotted key ("marketing.email") is illegal as a Mongo FIELD name but fine as a plain
36
+ // STRING VALUE inside an array element — the exact reason `decisions` is an array and not
37
+ // an object keyed by consent key (see @owlmeans/server-marketing-consent's model.ts).
38
+ decisions: [decision('marketing.email'), decision('trackers.advertising')],
39
+ createdAt: now,
40
+ updatedAt: now,
41
+ })
42
+
43
+ expect(typeof created.id).toBe('string')
44
+
45
+ const reread = await state.get(created.id!)
46
+ expect(reread.subject).toBe('subject-1')
47
+ expect(reread.userId).toBe('user-1')
48
+ expect(reread.decisions).toHaveLength(2)
49
+ expect(reread.decisions.map(d => d.key)).toEqual(['marketing.email', 'trackers.advertising'])
50
+ expect(reread.decisions[0]).toMatchObject(decision('marketing.email'))
51
+ expect(reread.createdAt).toBe(now)
52
+ expect(reread.updatedAt).toBe(now)
53
+ })
54
+
55
+ it('rejects a second state record for the same subject', async () => {
56
+ const { context } = await suite.boot()
57
+ const state = context.resource<MongoResource<MarketingConsentStateRecord>>(RES_MARKETING_CONSENT_STATE)
58
+
59
+ const now = new Date().toISOString()
60
+ await state.create({
61
+ subject: 'subject-dup', userId: 'user-a', decisions: [], createdAt: now, updatedAt: now,
62
+ })
63
+
64
+ await expect(state.create({
65
+ subject: 'subject-dup', userId: 'user-b', decisions: [], createdAt: now, updatedAt: now,
66
+ })).rejects.toThrow()
67
+ })
68
+
69
+ it('stores an append-only log record', async () => {
70
+ const { context } = await suite.boot()
71
+ const log = context.resource<MongoResource<MarketingConsentLogRecord>>(RES_MARKETING_CONSENT_LOG)
72
+
73
+ const now = new Date().toISOString()
74
+ const created = await log.create({
75
+ subject: 'subject-log', userId: 'user-log', kind: 'consent', key: 'marketing.email',
76
+ granted: true, revisedAt: now, mode: 'opt-in', decidedAt: now, source: 'settings',
77
+ })
78
+
79
+ expect(typeof created.id).toBe('string')
80
+ const reread = await log.get(created.id!)
81
+ expect(reread.key).toBe('marketing.email')
82
+ expect(reread.kind).toBe('consent')
83
+ })
84
+
85
+ it('appendMarketingConsentMongo is a no-op the second time it runs', async () => {
86
+ const { context } = await suite.boot()
87
+
88
+ expect(context.hasResource(RES_MARKETING_CONSENT_STATE)).toBe(true)
89
+ expect(context.hasResource(RES_MARKETING_CONSENT_LOG)).toBe(true)
90
+
91
+ const state = context.resource<MongoResource<MarketingConsentStateRecord>>(RES_MARKETING_CONSENT_STATE)
92
+ const log = context.resource<MongoResource<MarketingConsentLogRecord>>(RES_MARKETING_CONSENT_LOG)
93
+
94
+ expect(() => appendMarketingConsentMongo(context)).not.toThrow()
95
+
96
+ // Same resource instances — nothing was re-registered.
97
+ expect(context.resource<MongoResource<MarketingConsentStateRecord>>(RES_MARKETING_CONSENT_STATE)).toBe(state)
98
+ expect(context.resource<MongoResource<MarketingConsentLogRecord>>(RES_MARKETING_CONSENT_LOG)).toBe(log)
99
+ })
100
+ })
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { MarketingConsentLogSchema, MarketingConsentStateSchema } from '@owlmeans/server-marketing-consent'
3
+ import { makeMarketingConsentLogMongo, makeMarketingConsentStateMongo } from '../src/resource.js'
4
+
5
+ /**
6
+ * Needs no live Mongo. Proves the two makers build the collection validator FROM the shared
7
+ * schemas in `@owlmeans/server-marketing-consent` — `properties` is the exact same object,
8
+ * nothing is redeclared — while narrowing `required` to drop `id`, which a stored Mongo document
9
+ * never carries (see the comment on `forMongoValidator` in `../src/resource.ts` for why: handing
10
+ * the schema to Mongo's `$jsonSchema` validator as-is makes every `create()` fail).
11
+ */
12
+ describe('@owlmeans/marketing-consent-mongo — schema wiring', () => {
13
+ test('the state resource shares the state schema\'s properties and drops `id` from required', () => {
14
+ const resource = makeMarketingConsentStateMongo()
15
+ const schema = resource.schema as { properties: unknown, required: string[] }
16
+
17
+ expect(schema.properties).toBe(MarketingConsentStateSchema.properties as never)
18
+ expect(schema.required).toEqual(
19
+ (MarketingConsentStateSchema.required as string[]).filter(field => field !== 'id')
20
+ )
21
+ expect(schema.required).not.toContain('id')
22
+ })
23
+
24
+ test('the log resource shares the log schema\'s properties and drops `id` from required', () => {
25
+ const resource = makeMarketingConsentLogMongo()
26
+ const schema = resource.schema as { properties: unknown, required: string[] }
27
+
28
+ expect(schema.properties).toBe(MarketingConsentLogSchema.properties as never)
29
+ expect(schema.required).toEqual(
30
+ (MarketingConsentLogSchema.required as string[]).filter(field => field !== 'id')
31
+ )
32
+ expect(schema.required).not.toContain('id')
33
+ })
34
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
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": [
11
+ "./dist/**/*",
12
+ "./build/**/*",
13
+ "./tests/**/*",
14
+ "./*.ts"
15
+ ]
16
+ }