@buildinams/core 0.3.0 → 0.4.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.
Files changed (82) hide show
  1. package/README.md +24 -1
  2. package/dist/analytics/google/index.d.ts +158 -0
  3. package/dist/analytics/google/index.js +274 -0
  4. package/dist/analytics/google/index.js.map +1 -0
  5. package/dist/analytics/index.d.ts +101 -0
  6. package/dist/analytics/index.js +384 -0
  7. package/dist/analytics/index.js.map +1 -0
  8. package/dist/analytics/shopify/index.d.ts +186 -0
  9. package/dist/analytics/shopify/index.js +175 -0
  10. package/dist/analytics/shopify/index.js.map +1 -0
  11. package/dist/{chunk-IAAQVL6M.js → chunk-FJ7SNQQI.js} +24 -5
  12. package/dist/chunk-FJ7SNQQI.js.map +1 -0
  13. package/dist/chunk-G2BKC4BU.js +8 -0
  14. package/dist/chunk-G2BKC4BU.js.map +1 -0
  15. package/dist/chunk-GQVN2ECX.js +14 -0
  16. package/dist/chunk-GQVN2ECX.js.map +1 -0
  17. package/dist/chunk-LL3VTFNY.js +22 -0
  18. package/dist/chunk-LL3VTFNY.js.map +1 -0
  19. package/dist/chunk-M3HNM5HC.js +48 -0
  20. package/dist/chunk-M3HNM5HC.js.map +1 -0
  21. package/dist/chunk-MHFGMH3G.js +11 -0
  22. package/dist/chunk-MHFGMH3G.js.map +1 -0
  23. package/dist/chunk-OS2T6P2T.js +50 -0
  24. package/dist/chunk-OS2T6P2T.js.map +1 -0
  25. package/dist/chunk-QIJGG2F4.js +51 -0
  26. package/dist/chunk-QIJGG2F4.js.map +1 -0
  27. package/dist/consent/cookiebot/index.d.ts +16 -0
  28. package/dist/consent/cookiebot/index.js +27 -0
  29. package/dist/consent/cookiebot/index.js.map +1 -0
  30. package/dist/consent/google/index.d.ts +23 -0
  31. package/dist/consent/google/index.js +48 -0
  32. package/dist/consent/google/index.js.map +1 -0
  33. package/dist/consent/index.d.ts +1 -0
  34. package/dist/consent/index.js +3 -0
  35. package/dist/consent/index.js.map +1 -0
  36. package/dist/consent/onetrust/index.d.ts +23 -0
  37. package/dist/consent/onetrust/index.js +39 -0
  38. package/dist/consent/onetrust/index.js.map +1 -0
  39. package/dist/consent/shopify/index.d.ts +44 -0
  40. package/dist/consent/shopify/index.js +84 -0
  41. package/dist/consent/shopify/index.js.map +1 -0
  42. package/dist/cookies/index.d.ts +230 -0
  43. package/dist/cookies/index.js +188 -0
  44. package/dist/cookies/index.js.map +1 -0
  45. package/dist/define-events-CxZjoUlG.d.ts +255 -0
  46. package/dist/destination-5KJ5PeR3.d.ts +122 -0
  47. package/dist/index-Zef1nnTP.d.ts +36 -0
  48. package/dist/index.d.ts +2 -2
  49. package/dist/index.js +64 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/listeners-DkU-vBPa.d.ts +15 -0
  52. package/dist/navigation/index.d.ts +1 -1
  53. package/dist/navigation/index.js +1 -1
  54. package/dist/schema/index.d.ts +16 -9
  55. package/dist/schema/index.js +1 -1
  56. package/dist/schema-CfQvhbuU.d.ts +87 -0
  57. package/dist/store/index.js +1 -1
  58. package/dist/tracking/cart/index.d.ts +74 -0
  59. package/dist/tracking/cart/index.js +118 -0
  60. package/dist/tracking/cart/index.js.map +1 -0
  61. package/dist/tracking/dom/index.d.ts +320 -0
  62. package/dist/tracking/dom/index.js +1196 -0
  63. package/dist/tracking/dom/index.js.map +1 -0
  64. package/docs/analytics.md +883 -0
  65. package/docs/cookies.md +354 -0
  66. package/docs/decisions/001-analytics-project-events.md +31 -0
  67. package/docs/decisions/002-tracking-dom-opt-in.md +29 -0
  68. package/docs/decisions/003-tracking-element-data.md +27 -0
  69. package/docs/decisions/004-cookies-explicit-store.md +26 -0
  70. package/docs/decisions/005-cookies-invalid-reads-fall-back.md +27 -0
  71. package/docs/decisions/006-cookies-server-only-types.md +19 -0
  72. package/docs/decisions/007-cookies-options-on-definition.md +25 -0
  73. package/docs/decisions/008-cookies-in-vertical-slices.md +24 -0
  74. package/docs/decisions/009-analytics-consent-targets.md +29 -0
  75. package/docs/decisions/010-analytics-destination-contract.md +29 -0
  76. package/docs/decisions/011-tracking-decoupled.md +30 -0
  77. package/docs/tracking.md +221 -0
  78. package/package.json +84 -3
  79. package/dist/chunk-IAAQVL6M.js.map +0 -1
  80. package/dist/chunk-SIU7SKJK.js +0 -139
  81. package/dist/chunk-SIU7SKJK.js.map +0 -1
  82. package/dist/schema-iy92gbTC.d.ts +0 -36
@@ -0,0 +1,354 @@
1
+ # Cookies
2
+
3
+ Typesafe cookies with one API for the server (Next.js) and the client.
4
+
5
+ ```ts
6
+ import {
7
+ browserCookies,
8
+ cookieSchema,
9
+ defineCookie,
10
+ } from "@buildinams/core/cookies";
11
+ ```
12
+
13
+ - **Define once**: name, value type, default and attributes live in one `defineCookie()` call.
14
+ - **Same API everywhere**: `cookie.get(store)`, `cookie.set(store, value)`, `cookie.delete(store)`. Only the `store` differs per environment.
15
+ - **Safe reads**: cookies are user input. A missing _or invalid_ value returns the fallback (`defaultValue`, or `null`), never a wrongly typed value.
16
+
17
+ ## Quick start
18
+
19
+ ### 1. Define the cookie
20
+
21
+ Define a cookie next to the code it belongs to, in its vertical slice: the session cookie with the auth logic, the
22
+ cart cookie with the cart. Keep the definition in a module **without** `"use client"` / `"use server"`, so both
23
+ environments can import it.
24
+
25
+ ```ts
26
+ // src/features/theme/cookies.ts
27
+ import { cookieSchema, defineCookie } from "@buildinams/core/cookies";
28
+
29
+ export const themeCookie = defineCookie({
30
+ name: "theme",
31
+ schema: cookieSchema.enum(["light", "dark"]),
32
+ defaultValue: "light",
33
+ options: { maxAge: 60 * 60 * 24 * 365, sameSite: "lax" },
34
+ });
35
+ ```
36
+
37
+ ### 2. Pick the store for your environment
38
+
39
+ | Where | Read with | Write with |
40
+ | ------------------------------- | --------------------------------------------------- | ---------------------------------------- |
41
+ | Server component | `await cookies()` | ❌ not allowed by Next.js |
42
+ | Server action / route handler | `await cookies()` | `await cookies()` |
43
+ | Proxy / middleware | `request.cookies` | `response.cookies` (from `NextResponse`) |
44
+ | Client component / browser code | `browserCookies` | `browserCookies` |
45
+ | Client component, reactive | `useCookieValue()` from `@buildinams/react/cookies` | `browserCookies` |
46
+
47
+ `cookies` is imported from `next/headers`.
48
+
49
+ ### 3. Use it
50
+
51
+ ```ts
52
+ // Server component
53
+ import { cookies } from "next/headers";
54
+ import { themeCookie } from "@/features/theme/cookies";
55
+
56
+ const theme = themeCookie.get(await cookies()); // "light" | "dark"
57
+ ```
58
+
59
+ ```ts
60
+ // Server action
61
+ "use server";
62
+
63
+ import { cookies } from "next/headers";
64
+ import { themeCookie } from "@/features/theme/cookies";
65
+
66
+ export const setTheme = async (theme: "light" | "dark") => {
67
+ themeCookie.set(await cookies(), theme);
68
+ };
69
+ ```
70
+
71
+ ```ts
72
+ // src/proxy.ts (Next 16+). Next 15 and earlier: src/middleware.ts exporting `middleware`, same body.
73
+ import { NextResponse, type NextRequest } from "next/server";
74
+ import { visitsCookie } from "@/features/visits/cookies"; // cookieSchema.number(), defaultValue: 0
75
+
76
+ export const proxy = (request: NextRequest) => {
77
+ const response = NextResponse.next();
78
+ visitsCookie.set(response.cookies, visitsCookie.get(request.cookies) + 1);
79
+ return response;
80
+ };
81
+ ```
82
+
83
+ ```ts
84
+ // Browser (event handler, effect, non-React code)
85
+ import { browserCookies } from "@buildinams/core/cookies";
86
+ import { themeCookie } from "@/features/theme/cookies";
87
+
88
+ themeCookie.set(browserCookies, "dark");
89
+ themeCookie.get(browserCookies); // "dark"
90
+ themeCookie.delete(browserCookies);
91
+ ```
92
+
93
+ ### 4. React
94
+
95
+ Read with `useCookieValue` from `@buildinams/react/cookies`; write with the cookie itself, exactly as anywhere else.
96
+
97
+ The server can't see the browser's cookies from a client component, so read the cookie in the server component and
98
+ pass it to the hook as the second argument:
99
+
100
+ ```tsx
101
+ // app/page.tsx (server component)
102
+ import { cookies } from "next/headers";
103
+ import { themeCookie } from "@/features/theme/cookies";
104
+ import { ThemeToggle } from "./theme-toggle";
105
+
106
+ export default async function Page() {
107
+ return <ThemeToggle serverTheme={themeCookie.get(await cookies())} />;
108
+ }
109
+ ```
110
+
111
+ ```tsx
112
+ // app/theme-toggle.tsx
113
+ "use client";
114
+
115
+ import type { CookieReadValue } from "@buildinams/core/cookies";
116
+ import { browserCookies } from "@buildinams/core/cookies";
117
+ import { useCookieValue } from "@buildinams/react/cookies";
118
+ import { themeCookie } from "@/features/theme/cookies"; // defaultValue: "light"
119
+
120
+ type Theme = CookieReadValue<typeof themeCookie>; // "light" | "dark"
121
+
122
+ export const ThemeToggle = ({ serverTheme }: { serverTheme: Theme }) => {
123
+ const theme = useCookieValue(themeCookie, serverTheme); // "light" | "dark"
124
+
125
+ return (
126
+ <>
127
+ <button
128
+ onClick={() =>
129
+ themeCookie.set(browserCookies, theme === "light" ? "dark" : "light")
130
+ }
131
+ >
132
+ {theme}
133
+ </button>
134
+ <button onClick={() => themeCookie.delete(browserCookies)}>Reset</button>
135
+ </>
136
+ );
137
+ };
138
+ ```
139
+
140
+ `useCookieValue(cookie, serverValue?)` returns:
141
+
142
+ - On the server and during hydration: `serverValue`, or `undefined` without one (the value isn't known yet, even
143
+ with a `defaultValue`).
144
+ - After hydration: the parsed value, or the cookie's `defaultValue` (`null` without one) when it's missing or invalid.
145
+ A cookie deleted in the browser reads as `null`, never as the stale `serverValue`.
146
+
147
+ Without a server value, handle `undefined` yourself. A consent banner, for example, should only show once the cookie is
148
+ known to be missing, or it flashes for users who already answered:
149
+
150
+ ```tsx
151
+ const consent = useCookieValue(consentCookie); // Consent | null | undefined
152
+
153
+ if (consent !== null) return null; // not known yet, or already answered
154
+ return <ConsentBanner />;
155
+ ```
156
+
157
+ - **Hydration never mismatches**, as long as `serverValue` is exactly what the server rendered (the prop from the
158
+ server component). Don't compute it differently on the client.
159
+ - **Don't apply a server value with `??`**: `useCookieValue(c) ?? serverValue` also replaces `null` (deleted in the
160
+ browser) with the stale server value. Pass it as the second argument instead.
161
+ - **httpOnly cookies are a compile error**: the browser can't read them. Don't pass their values to client
162
+ components either.
163
+ - Re-renders after writes through `browserCookies`, and on `cookieStore` change events, window focus and tab visibility
164
+ changes. A cookie set by a server action shows up via `cookieStore` change events (or the next focus in browsers
165
+ without the Cookie Store API).
166
+
167
+ ## Defining cookies
168
+
169
+ ```ts
170
+ defineCookie({
171
+ name: string, // cookie name in the browser
172
+ schema: CookieSchema<Value>, // how Value <-> string is converted (see below)
173
+ defaultValue?: Value, // returned when missing/invalid; without it reads return `Value | null`
174
+ options?: CookieOptions, // attributes applied on every write
175
+ });
176
+ ```
177
+
178
+ ### Schemas
179
+
180
+ | Schema | Value type | Stored as |
181
+ | ----------------------------- | ---------------------------- | ----------------------- |
182
+ | `cookieSchema.string()` | `string` | as-is |
183
+ | `cookieSchema.number()` | `number` (finite) | `"42"` |
184
+ | `cookieSchema.boolean()` | `boolean` | `"true"` / `"false"` |
185
+ | `cookieSchema.enum([...])` | union of the listed literals | as-is |
186
+ | `cookieSchema.json(validate)` | return type of `validate` | `JSON.stringify(value)` |
187
+
188
+ `json` requires a validator, because a cookie can contain anything. A zod/valibot `parse` fits directly:
189
+
190
+ ```ts
191
+ const ConsentSchema = z.object({
192
+ analytics: z.boolean(),
193
+ marketing: z.boolean(),
194
+ });
195
+
196
+ export const consentCookie = defineCookie({
197
+ name: "consent",
198
+ schema: cookieSchema.json(ConsentSchema.parse),
199
+ });
200
+ ```
201
+
202
+ Need the same validation elsewhere (form data, request bodies)? Export the schema next to the cookie and reuse it:
203
+
204
+ ```ts
205
+ export const themeSchema = cookieSchema.enum(["light", "dark"]);
206
+ export const themeCookie = defineCookie({
207
+ name: "theme",
208
+ schema: themeSchema,
209
+ defaultValue: "light",
210
+ });
211
+
212
+ // server action
213
+ const theme = themeSchema.parse(String(formData.get("theme"))); // throws on invalid input
214
+ ```
215
+
216
+ Custom schema: any object with `parse(raw: string): Value` (throws when invalid) and `serialize(value: Value): string`. Don't URL-encode in `serialize`; the store does that.
217
+
218
+ ```ts
219
+ const dateSchema: CookieSchema<Date> = {
220
+ parse: (raw) => {
221
+ const date = new Date(raw);
222
+ if (Number.isNaN(date.getTime())) throw new Error("Invalid date");
223
+ return date;
224
+ },
225
+ serialize: (value) => value.toISOString(),
226
+ };
227
+ ```
228
+
229
+ ### Options
230
+
231
+ Fixed on the definition and applied to every write and to `delete()`. Same names as Next.js' `cookies().set()` options.
232
+
233
+ | Option | Default | Notes |
234
+ | ------------- | ----------------------- | ------------------------------------------------------------------------ |
235
+ | `path` | `"/"` | |
236
+ | `domain` | — | |
237
+ | `maxAge` | session | Seconds, relative to the write |
238
+ | `secure` | `false` | Required when `sameSite: "none"` |
239
+ | `sameSite` | browser default (`lax`) | `"lax"`, `"strict"`, `"none"` |
240
+ | `httpOnly` | `false` | Hidden from client JS. Makes the definition a `ServerCookie` (see below) |
241
+ | `partitioned` | `false` | |
242
+ | `priority` | — | `"low"`, `"medium"`, `"high"` |
243
+
244
+ A single write can override the lifetime only: `cookie.set(store, value, { maxAge: 60 })` or `{ expires: date }`.
245
+ Everything else stays on the definition, so `delete()` always targets the same cookie. There's no `expires` on the
246
+ definition: it would be computed once, when the module loads.
247
+
248
+ ### Server-only cookies
249
+
250
+ `httpOnly: true` returns a `ServerCookie`. It has the same API, but passing `browserCookies` (or using it with
251
+ `useCookieValue`) is a compile error. The browser can't read or write it.
252
+
253
+ ```ts
254
+ export const sessionCookie = defineCookie({
255
+ name: "session",
256
+ schema: cookieSchema.string(),
257
+ options: { httpOnly: true, secure: true, sameSite: "lax" },
258
+ });
259
+
260
+ sessionCookie.get(await cookies()); // ✅
261
+ sessionCookie.get(browserCookies); // ❌ type error
262
+ ```
263
+
264
+ ## API
265
+
266
+ ```ts
267
+ const cookie = defineCookie({ ... });
268
+
269
+ cookie.get(store) // Value with a defaultValue, Value | null without
270
+ cookie.set(store, value, lifetime?) // lifetime: { maxAge?, expires? }
271
+ cookie.delete(store)
272
+ cookie.name
273
+ cookie.httpOnly
274
+ ```
275
+
276
+ Types:
277
+
278
+ ```ts
279
+ import type {
280
+ Cookie, // Cookie<Value, HasDefault>: a cookie usable on server and client
281
+ ServerCookie, // ServerCookie<Value, HasDefault>: an httpOnly cookie, server stores only
282
+ CookieWriteValue, // CookieWriteValue<typeof themeCookie> -> "light" | "dark"
283
+ CookieReadValue, // CookieReadValue<typeof consentCookie> -> Consent | null
284
+ CookieSchema, // { parse(raw): Value; serialize(value): string }
285
+ CookieReader, // { get(name) }: any readable store
286
+ CookieWriter, // { set(name, value, attributes) }: any writable store
287
+ } from "@buildinams/core/cookies";
288
+
289
+ // accept any theme-like cookie, with or without a default
290
+ const label = (cookie: Cookie<"light" | "dark">) => cookie.name;
291
+ ```
292
+
293
+ `CookieReader` / `CookieWriter` are structural: Next.js' cookie stores satisfy them without adapters. For other
294
+ frameworks or tests, pass any object with matching `get` / `set`.
295
+
296
+ ## Rules (for humans and agents)
297
+
298
+ 1. **Never read or write a cookie by its raw name.** Add a `defineCookie()` to the slice that owns the cookie and use that.
299
+ 2. **Never `JSON.parse` / cast cookie values.** Pick or write a schema; reads are validated through it.
300
+ 3. **Pass the store explicitly.** Server: `await cookies()` / `request.cookies` / `response.cookies`. Client: `browserCookies`.
301
+ 4. **Don't write cookies in server components.** Use a server action, route handler or proxy (middleware before Next 16).
302
+ 5. **Keep httpOnly cookies on the server.** The types enforce it; read them on the server and pass down what the client needs.
303
+ 6. **Keep values small.** Browsers cap a cookie at ~4KB including its name and attributes.
304
+
305
+ ## Adding a cookie: checklist
306
+
307
+ 1. Add a `defineCookie()` export to the slice that owns the cookie (e.g. `src/features/auth/cookies.ts`), in a module without `"use client"` / `"use server"`.
308
+ 2. Choose a schema; add `defaultValue` if a missing cookie has a sensible meaning.
309
+ 3. Set `options` (`maxAge`, `sameSite`, `httpOnly` for server-only data).
310
+ 4. Read/write via `cookie.get(store)` / `cookie.set(store, value)` with the store from the table above.
311
+ 5. Unit-test logic that depends on the cookie by passing a fake store (see below).
312
+
313
+ ## Testing
314
+
315
+ Pass a plain object, or Next.js' cookie classes, as the store:
316
+
317
+ ```ts
318
+ import {
319
+ RequestCookies,
320
+ ResponseCookies,
321
+ } from "next/dist/server/web/spec-extension/cookies";
322
+
323
+ const request = new RequestCookies(new Headers({ cookie: "theme=dark" }));
324
+ expect(themeCookie.get(request)).toBe("dark");
325
+
326
+ const headers = new Headers();
327
+ themeCookie.set(new ResponseCookies(headers), "dark");
328
+ expect(headers.get("set-cookie")).toContain("theme=dark");
329
+
330
+ // minimal fake
331
+ const store = new Map<string, string>();
332
+ const fake = {
333
+ get: (name: string) =>
334
+ store.has(name) ? { value: store.get(name)! } : undefined,
335
+ set: (name: string, value: string) => void store.set(name, value),
336
+ };
337
+ ```
338
+
339
+ In the browser (jsdom), `browserCookies` works against `document.cookie` directly.
340
+
341
+ ## How it works
342
+
343
+ - Values are URL-encoded on write and decoded on read, matching Next.js. A cookie written in the browser reads the same on the server, and vice versa.
344
+ - `delete()` writes an empty value with `maxAge: 0` and an `expires` in the past, using the definition's `path` and `domain`.
345
+ - `browserCookies.subscribe(listener)` fires after `browserCookies.set()`, on `cookieStore` change events (where supported), on window `focus` and on `visibilitychange`.
346
+
347
+ ## Decisions
348
+
349
+ - [Cookies take their store as an argument](decisions/004-cookies-explicit-store.md)
350
+ - [Invalid cookies read as missing](decisions/005-cookies-invalid-reads-fall-back.md)
351
+ - [httpOnly cookies are server-only in the types](decisions/006-cookies-server-only-types.md)
352
+ - [Cookie attributes live on the definition](decisions/007-cookies-options-on-definition.md)
353
+ - [Cookies are defined in their vertical slice](decisions/008-cookies-in-vertical-slices.md)
354
+ - [`useCookieValue()` takes the server's value](../../react/docs/decisions/008-cookie-server-value.md) (`@buildinams/react`)
@@ -0,0 +1,31 @@
1
+ # Projects own their event catalog
2
+
3
+ Date: 2026-11-11
4
+
5
+ Status: accepted
6
+
7
+ ## Context
8
+
9
+ Projects track their own events (`newsletterSignedUp`, `orderPlaced`, …) with their own data: a product page has a `{ product, variant }` from its Storefront API query, a card a `{ product }`. Each provider names events its own way: GA4 `view_item`, Shopify `product_viewed`, Meta `ViewContent`. And developers, agents and analysts want to read which events a project tracks, what each means and where it goes, so the catalog can't only live in types.
10
+
11
+ Options considered:
12
+
13
+ - **A global `Register` interface** projects augment with `declare module`. One global the library's plugins can't constrain, with `.d.ts` pitfalls.
14
+ - **Canonical shapes** (`StandardEvents`, `AnalyticsItem`) that projects flatten their data into. Each destination rebuilds its provider's format, losing whatever the canonical item lacks.
15
+ - **A catalog value from the project**, with the library's structures generic over the project's data.
16
+
17
+ ## Decision
18
+
19
+ - **The catalog is a value:** `defineEvents({ name: { description, payload: shape<T>() } })`, passed to `createAnalytics<Events>({ events })`. `shape<T>()` holds nothing at runtime; `description` is required.
20
+ - **Trackers carry their entries.** `domWatcher.events` and `cartChanges.events` describe exactly what the installed plugins report, typed with the project's data as declared on the plugins, so shapes are declared once.
21
+ - **Library structure, project leaves.** What the library reports has a fixed structure, e.g. `itemViewed: { item, list?, index?, promotion? }`, with the project's item, list, promotion and cart item as leaves.
22
+ - **One vocabulary, no provider's.** Tracking's names (`pageViewed`, `itemsViewed`, `addedToCart`, …) are the catalog's; the project's own events follow them: camelCase, past tense. Provider names live in mappings ([ADR 010](010-analytics-destination-contract.md)).
23
+ - **`analytics.routes()`** reads the catalog against the destinations: per event, its description and who receives it, with what consent.
24
+
25
+ ## Consequences
26
+
27
+ - No module augmentation, so no `interface` exception. `Events` is passed explicitly: inferring it from `events` breaks `map()`'s inference of prebuilt mappings in the same call.
28
+ - There's no canonical item, so every destination needs a small adapter from the project's data (`ga4Mapping({ item })`, `shopifyMapping({ product })`).
29
+ - Payloads may hold rich objects, copied by `structuredClone` on every `track()`; the docs recommend lean fragments.
30
+ - Descriptions ship in the bundle. They're short, and they make the catalog readable at runtime.
31
+ - Development warns about catalog events no destination receives, and tracked names missing from the catalog.
@@ -0,0 +1,29 @@
1
+ # Opt-in markup tracking
2
+
3
+ Date: 2026-11-05
4
+
5
+ Status: accepted
6
+
7
+ ## Context
8
+
9
+ Tracking every `<a href>` and `<form>` unless an ancestor opts out tracks a lot nobody asked for on an ecommerce site:
10
+
11
+ - **Personal data in URLs:** password reset, order status and unsubscribe links send their tokens to GA4. One forgotten opt-out leaks them.
12
+ - **Noise and cost:** filters, pagination and swatches each become a `click`, billed per request by server-side GTM.
13
+ - **A broad switch:** an opt-out also silences product and promotion clicks inside it.
14
+
15
+ Options considered: opt-out per plugin, a click `location` as the opt-in, per-element types only, an opt-in for a subtree, a mode per plugin, and selectors in config.
16
+
17
+ ## Decision
18
+
19
+ Nothing is tracked from markup without a type in `data-track` that asks for it:
20
+
21
+ - `click` and `form` track that element.
22
+ - `auto-links` and `auto-forms` (`track({ auto })`) track every link or form inside. Buttons always need `click`, as their text rarely says what they do, and videos are never auto-tracked.
23
+ - The nearest `auto` decides, so `auto-none` (`track({ auto: false })`) turns it off for a subtree.
24
+
25
+ ## Consequences
26
+
27
+ - Forgetting a type loses a click rather than leaking a URL.
28
+ - Projects mark their header, navigation and footer once; a rich-text renderer wraps its output in `auto-links`.
29
+ - Analytics doesn't clean URLs: a query parameter allowlist was tried and dropped, as tags read the page URL themselves and GA4's data redaction covers it at the source.
@@ -0,0 +1,27 @@
1
+ # One `track()` marks an element, with data off the HTML
2
+
3
+ Date: 2026-11-05
4
+
5
+ Status: accepted
6
+
7
+ ## Context
8
+
9
+ Markup tracking read each element's data from a JSON attribute per kind (`data-track-item`, `data-track-promotion`, …), in a compact encoding to keep product grids small. That puts every product's data in the HTML, again in the server component payload, and needs its own parser and errors. A prototype (branch `prototype/track-dom-element-data`) compared JSON attributes, a hybrid, element data only, and splitting by size.
10
+
11
+ Splitting by size first took a helper per case: one for string attributes, one returning `{ ref, attributes }` for data, one to combine them. A promotion in the hero took three calls and a ref set separately.
12
+
13
+ ## Decision
14
+
15
+ The watcher's `track(spec)` marks an element: one object keyed by type, returning props to spread.
16
+
17
+ - **Attribute types** (`pageView`, `click`, `location`, `auto`, `video`, `form`, `scroll`) take a few strings, written as `data-track-<field>` attributes that server components and CMS markup can write. `true` when all fields are optional.
18
+ - **Data types** (`viewItem`, `quickView`, `list`, `item`, `promotion`) take the project's data, or a function building it. `track()` then also returns a `ref`, which sets it as a property on the element, under `Symbol.for()`, ignored when equal to the current data.
19
+ - **Types are listed in `data-track`**, kebab-case, so CMS markup writes them by hand. Without a framework, the `ref` called with an element sets the data and the type.
20
+
21
+ ## Consequences
22
+
23
+ - Several types on one element are one call, e.g. `track({ promotion, location: { name: "hero" } })`. An element with its own ref needs a ref that calls both.
24
+ - `track()` only returns a `ref` for data types, so server components spread it for attribute types. Product, list and promotion data needs a client component, and isn't tracked before hydration.
25
+ - The HTML carries only types: about 0.8 KB instead of 9 KB for a 48-product grid in the prototype.
26
+ - A forgotten `ref` fails silently, so `ecommerce()` warns in development about visible elements still without data after 2 seconds.
27
+ - Element data isn't in the Elements panel or GTM's DOM variables, only in the console under `Symbol.for("@buildinams/core/tracking/dom")`.
@@ -0,0 +1,26 @@
1
+ # Cookies take their store as an argument
2
+
3
+ Date: 2026-11-03
4
+
5
+ Status: accepted
6
+
7
+ ## Context
8
+
9
+ A Next.js project reads and writes cookies in five places, each with its own API: `await cookies()` in server components, server actions and route handlers, `request.cookies` and `response.cookies` in the proxy, and `document.cookie` in the browser. Server components can read but not write. Each cookie also has a name, a value type and attributes that every one of those places has to agree on.
10
+
11
+ Options considered:
12
+
13
+ - **Detect the environment** inside the cookie: `themeCookie.get()` calls `cookies()` on the server and reads `document.cookie` in the browser. The shortest call, but `cookies()` is async and only works in a request scope, the proxy has two stores to choose from, and the cookie module would import `next/headers`, tying it to Next.js.
14
+ - **A cookie per environment**: `serverCookie()` and `clientCookie()` wrappers. Each is simple, but the definition is repeated or split, and they drift.
15
+ - **The store as an argument**: one definition, and `get(store)`, `set(store, value)`, `delete(store)`.
16
+
17
+ ## Decision
18
+
19
+ A cookie from `defineCookie()` takes the store as its first argument. A store is structural: `CookieReader` is anything with `get(name)` returning `{ value } | undefined`, `CookieWriter` anything with `set(name, value, attributes)`. Next.js' `cookies()`, `RequestCookies` and `ResponseCookies` fit without adapters. `browserCookies` is the store for the browser, on `document.cookie`, and adds `subscribe()` for changes.
20
+
21
+ ## Consequences
22
+
23
+ - The same definition works in every environment, and which store a call uses is visible at the call site.
24
+ - `@buildinams/core/cookies` doesn't import Next.js. Other frameworks, and tests, pass any object with `get` and `set`.
25
+ - Writing in a server component is still Next.js' runtime error, not a type error: `await cookies()` has a `set` there too.
26
+ - Values are URL-encoded the way Next.js does, so a cookie written by one store reads the same in another.
@@ -0,0 +1,27 @@
1
+ # Invalid cookies read as missing
2
+
3
+ Date: 2026-11-03
4
+
5
+ Status: accepted
6
+
7
+ ## Context
8
+
9
+ Cookies are user input. Visitors edit them, old deploys leave values in an earlier format, other scripts write the same name, and a value can be cut off. A typed read that trusts the raw string returns a value of the wrong type with nothing to show for it.
10
+
11
+ Options considered:
12
+
13
+ - **Cast**: return the raw string as the value type. What untyped code does today, with the type checker's blessing.
14
+ - **Throw** on an invalid value. Loud, but one bad cookie breaks a page render, and every read needs a `try`.
15
+ - **A result type**: `{ ok: true, value } | { ok: false, error }`. Precise, but every caller unwraps it to do the same thing: use a fallback.
16
+ - **Fall back**: an invalid value reads like a missing one.
17
+
18
+ ## Decision
19
+
20
+ Every cookie has a schema, a `parse(raw)` that throws when the value is invalid and a `serialize(value)`. `get()` returns the parsed value, and when the cookie is missing or `parse` throws, the definition's `defaultValue`, or `null` without one. `cookieSchema` has `string`, `number`, `boolean`, `enum` and `json(validate)`; `json` requires a validator, which a zod or valibot `parse` fits directly.
21
+
22
+ ## Consequences
23
+
24
+ - A read never returns a wrongly typed value, and never throws.
25
+ - With a `defaultValue` the read type is `Value`, without one `Value | null`, so callers handle a missing cookie where it matters.
26
+ - An invalid cookie is silent. Debugging means calling the schema's `parse()` with the raw value, which throws with the reason.
27
+ - An invalid cookie stays in the browser until it's written or deleted.
@@ -0,0 +1,19 @@
1
+ # httpOnly cookies are server-only in the types
2
+
3
+ Date: 2026-11-03
4
+
5
+ Status: accepted
6
+
7
+ ## Context
8
+
9
+ An httpOnly cookie, like a session, can't be read or written from JavaScript in the browser. Read through `document.cookie` it's simply missing, so code that passes `browserCookies` to it doesn't fail: it reads the fallback, and a write is ignored. Both look like bugs somewhere else.
10
+
11
+ ## Decision
12
+
13
+ `defineCookie()` with `options: { httpOnly: true }` returns a `ServerCookie` instead of a `Cookie`. It has the same API, but its methods take server stores only: `browserCookies` carries a brand that `ServerCookieReader` and `ServerCookieWriter` exclude, so passing it is a compile error. `useCookieValue()` only accepts a `Cookie`. At runtime, `browserCookies.set()` still throws for an httpOnly write.
14
+
15
+ ## Consequences
16
+
17
+ - Reading a session cookie in a client component is caught by the type checker, not in production.
18
+ - `httpOnly` has to be a literal in the definition for the type to follow: an `httpOnly` from a variable typed `boolean` gives `Cookie | ServerCookie`, which callers have to narrow.
19
+ - What the client needs from an httpOnly cookie is read on the server and passed down, so it's a deliberate choice.
@@ -0,0 +1,25 @@
1
+ # Cookie attributes live on the definition
2
+
3
+ Date: 2026-11-03
4
+
5
+ Status: accepted
6
+
7
+ ## Context
8
+
9
+ A cookie is identified by its name, `path` and `domain`. A `delete()`, or a write, with another `path` or `domain` than the original write creates a second cookie with the same name, or leaves the old one in place. Attributes like `sameSite` and `secure` are easy to forget on one write among many.
10
+
11
+ Options considered:
12
+
13
+ - **Attributes per write**, like Next.js' `cookies().set()`. Flexible, but every call site repeats them and can get them wrong.
14
+ - **Defaults on the definition, overridable per write.** Shorter, but an override of `path` or `domain` breaks `delete()` in the same way.
15
+ - **Fixed on the definition, with only the lifetime per write.**
16
+
17
+ ## Decision
18
+
19
+ `defineCookie({ options })` fixes `path` (default `"/"`), `domain`, `maxAge`, `secure`, `sameSite`, `httpOnly`, `partitioned` and `priority`, with Next.js' names. `set(store, value, { maxAge })` or `{ expires }` can change the lifetime of one write. `delete()` writes an empty value with `maxAge: 0` and the definition's `path` and `domain`. There's no `expires` on the definition.
20
+
21
+ ## Consequences
22
+
23
+ - `delete()` always targets the cookie `set()` wrote.
24
+ - A cookie that needs other attributes in some places, like another `path`, is a second definition.
25
+ - `expires` on the definition would be computed once, when the module loads, and go stale in a long-running server. A lifetime that lasts is a `maxAge`; a fixed date is an `expires` per write.
@@ -0,0 +1,24 @@
1
+ # Cookies are defined in their vertical slice
2
+
3
+ Date: 2026-11-08
4
+
5
+ Status: accepted
6
+
7
+ ## Context
8
+
9
+ Every cookie is a `defineCookie()` call, and code reads and writes it only through that definition. The definitions need a home in the project.
10
+
11
+ Options considered:
12
+
13
+ - **One shared module**, e.g. `src/cookies.ts`, with every cookie. Easy to audit, but it couples unrelated features, grows into a file every feature changes, and pulls auth's cookies into the theme toggle's imports.
14
+ - **In the slice that owns the cookie**: the session cookie with the auth logic, the cart cookie with the cart.
15
+
16
+ ## Decision
17
+
18
+ A cookie is defined next to the code it belongs to, e.g. `src/features/auth/cookies.ts`. The module has no `"use client"` or `"use server"`, so both server and client code can import it.
19
+
20
+ ## Consequences
21
+
22
+ - A feature's cookies change, and are removed, with the feature.
23
+ - There's no single list of a project's cookies. Search for `defineCookie(` for an inventory, e.g. for a cookie policy.
24
+ - A cookie read by several slices, like consent, belongs to the slice that writes it, and the others import it from there.
@@ -0,0 +1,29 @@
1
+ # Consent targets are separate from destinations
2
+
3
+ Date: 2026-11-08
4
+
5
+ Status: accepted
6
+
7
+ ## Context
8
+
9
+ Providers play up to three roles around consent:
10
+
11
+ | Role | Does | Providers |
12
+ | --------------------- | --------------- | ----------------------------------------------------------- |
13
+ | **Consent source** | Decides consent | Cookiebot, OneTrust, Shopify's privacy banner |
14
+ | **Consent target** | Is told consent | Google Consent Mode, Shopify Customer Privacy, Meta consent |
15
+ | **Event destination** | Receives events | GTM, Shopify's analytics bus, Meta Pixel |
16
+
17
+ With only sources and destinations, destinations forwarded consent themselves, behind flags (`forwardConsent`, `setConsentMode`) that encoded which consent platform the project uses. A wrong flag failed silently.
18
+
19
+ Options considered: sources that describe themselves, integrations bundling a provider's roles, consent as an event on the bus, and consent targets as a third role.
20
+
21
+ ## Decision
22
+
23
+ `createAnalytics({ consentTargets })` takes objects with a `name` and `apply(consent)`. Each answer is applied to every target before destinations receive events under it, except a target named like the source, so consent is never written back to its platform. Destinations only handle events, and get the current answer with each one.
24
+
25
+ ## Consequences
26
+
27
+ - Switching consent platforms changes `consent`, and adds or removes `googleConsentMode()`. No destination changes.
28
+ - A source and its platform's target must share a name, e.g. `"shopify"`.
29
+ - A provider that needs both roles, like Meta, is a target and a destination, wired up separately.
@@ -0,0 +1,29 @@
1
+ # Destinations declare what they accept; projects map onto it
2
+
3
+ Date: 2026-11-10
4
+
5
+ Status: accepted
6
+
7
+ ## Context
8
+
9
+ Every destination received every event and mapped it to its provider internally, with a `switch` that dropped what it didn't handle and an untyped `map` option to adjust the result. What a destination needed (Shopify drops products without `variantId` or `price`) was a runtime warning, not a type.
10
+
11
+ Options considered: mapping as an option on each destination, or a typed contract with mapping in core, as one call with consent or two.
12
+
13
+ ## Decision
14
+
15
+ - **A destination is `AnalyticsDestination<Accepts>`**, shaped like Hydrogen's `StorefrontAnalyticsDestination`: a `name`, and `setup({ subscribe })` that subscribes to each event it accepts, in its provider's vocabulary. `setup` runs once, before consent, and mustn't load the provider's script.
16
+ - **`map(destination, mapping)`** maps the project's events onto it, returning a destination that accepts the project's events and lists them in `events`, for `routes()`. Each entry returns one of the destination's events, several, or `null`.
17
+ - **`withConsent(destination, requires)`** adds the consent it requires, which is all `createAnalytics()` takes. `requires` is mandatory: the categories, or `"none"` for providers that gate on consent themselves. A default of `"none"` would send a forgotten marketing pixel events before the visitor answers.
18
+ - **`mapEvery(events, toEvent)`** maps every catalog event, spread before specific entries; a destination can't fall back by itself, as it only knows the events it subscribes to.
19
+ - **Builders per destination** map what tracking reports over the project's adapters: `ga4Mapping<Events>({ item, … })` and `shopifyMapping<Events>({ product, … })`, spread to extend or override. `toGa4CustomEvent` sends other events under their name in snake_case.
20
+
21
+ A type prototype checked about 120 events with 150 mapping entries in 0.16 s.
22
+
23
+ ## Consequences
24
+
25
+ - A wrong event, a missing required field or an entry for an undeclared event is a compile error. `map()` rejects unknown keys itself, as inferred mappings skip excess property checks.
26
+ - Whether a provider counts as analytics or marketing is the project's call, per destination.
27
+ - A destination typed by the project's events, e.g. a log, needs only `withConsent()`.
28
+ - It's Hydrogen's pattern, not its type: a Hydrogen destination can't be used as-is.
29
+ - A destination mapped more than once is set up once.