@profullstack/affiliates 0.1.0 → 0.2.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 CHANGED
@@ -13,8 +13,9 @@ Scarce invite codes plus open affiliate links, for every Profullstack property.
13
13
  (`calculateReferral`, `DEFAULT_SPLIT` = 60% affiliate, 20% customer discount), and its
14
14
  `buildReferralUrl` / `extractCode` are re-exported.
15
15
 
16
- Stores: in-memory, SQLite (`bun:sqlite`, `node:sqlite`, `better-sqlite3`) and Postgres (anything
17
- with a node-postgres style `query`). SQL stores create their own tables (prefix `aff_`).
16
+ Stores: in-memory, SQLite (`bun:sqlite`, `node:sqlite`, `better-sqlite3`), Postgres (anything
17
+ with a node-postgres style `query`) and Supabase (a supabase-js client, through `rpc()` only). SQL
18
+ stores create their own tables (prefix `aff_`); the Supabase store ships its schema as a migration.
18
19
 
19
20
  ```sh
20
21
  npm i @profullstack/affiliates
@@ -52,6 +53,72 @@ import { Affiliates, postgresStore } from '@profullstack/affiliates';
52
53
  const aff = new Affiliates(postgresStore(new pg.Pool({ connectionString: process.env.DATABASE_URL })));
53
54
  ```
54
55
 
56
+ ## On Supabase
57
+
58
+ For apps that only have a supabase-js client (service role key, no direct `DATABASE_URL`). Every
59
+ operation is one `client.rpc()` call to a Postgres function, so the scarce parts are atomic in the
60
+ database, not read-modify-write in JS: the quota check and insert run under a per-inviter advisory
61
+ lock (the same logic as `postgresStore`'s `aff_insert_invite()`), redemption is
62
+ `UPDATE ... WHERE used_by IS NULL`, and attribution is `INSERT ... ON CONFLICT DO NOTHING`.
63
+ `@supabase/supabase-js` is not a dependency; anything with `rpc(fn, args) -> { data, error }` works.
64
+
65
+ 1. Add the migration (tables, RLS on with no policies, `SECURITY DEFINER` functions with a pinned
66
+ `search_path`, executable only by `service_role`):
67
+
68
+ ```sh
69
+ cp node_modules/@profullstack/affiliates/migrations/supabase.sql supabase/migrations/20261026000000_affiliates.sql
70
+ # other prefix: node -e "import('@profullstack/affiliates').then(m => process.stdout.write(m.supabaseMigrationSql('gc_')))" > ...
71
+ ```
72
+
73
+ 2. Use a service-role client on the server only:
74
+
75
+ ```ts
76
+ // lib/affiliates.ts (server only)
77
+ import { createClient } from '@supabase/supabase-js';
78
+ import { Affiliates, supabaseStore } from '@profullstack/affiliates';
79
+ let aff: Affiliates | undefined;
80
+ export const getAffiliates = () => (aff ??= new Affiliates(supabaseStore(createClient(
81
+ process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { auth: { persistSession: false } }))));
82
+
83
+ // app/auth/callback/route.ts, after exchangeCodeForSession() gave you `user`
84
+ import { refFromRequest } from '@profullstack/affiliates';
85
+ const ref = refFromRequest(request); // ?ref= or the referral_code cookie
86
+ if (ref) await getAffiliates().attribute(ref, user.id); // { external: true } = not ours, see below
87
+ ```
88
+
89
+ Build the client lazily (as above), not at module top level: Next.js inlines `process.env.X` at
90
+ build time, and a secret that is only present at runtime would compile in as `undefined`.
91
+
92
+ The tables are the same as `postgresStore`'s, so a script with a direct Postgres URL can use
93
+ `postgresStore` on the same data. `supabaseStore(client, { prefix })` must match the prefix the
94
+ migration was generated with. The anon and authenticated keys can neither read the tables nor call
95
+ the functions.
96
+
97
+ ## Already using @profullstack/referrals
98
+
99
+ Sites that already ship `@profullstack/referrals` keep it: its tables, its `trackReferralCode`
100
+ middleware and its single `referral_code` cookie. Affiliates reads that cookie instead of setting
101
+ its own, so one sign-up is never counted twice.
102
+
103
+ - **Do not set a second cookie.** In the signup or auth callback, take the code with
104
+ `refFromRequest(request)` (it reads `?ref=` and then `REF_COOKIE`, which is `'referral_code'`, the
105
+ name `@profullstack/referrals/next` sets) and pass it to `aff.attribute(code, newUserId)`.
106
+ - **Codes that are theirs come back `{ ok: false, external: true }`** and nothing is recorded; hand
107
+ them to the existing referrals flow (`applyReferral`, the site's own usage table). Our codes come
108
+ back `{ ok: true, affiliateId }`. Unknown codes come back `{ ok: false }`.
109
+ - How "theirs" is decided: `supabaseStore` checks the `referral_codes` table from
110
+ `20260602000000_referrals.sql` when it exists. Other stores take
111
+ `new Affiliates(store, { externalCodes })`, where `externalCodes` is your @profullstack/referrals
112
+ `ReferralStore` (its `getCode` is used) or a function `(code) => boolean | Promise<boolean>`.
113
+
114
+ ```ts
115
+ const code = refFromRequest(request);
116
+ if (code) {
117
+ const r = await aff.attribute(code, user.id);
118
+ if (r.external) await applyReferral({ code, newUserId: user.id, amountCents, store: referralStore });
119
+ }
120
+ ```
121
+
55
122
  ## Invite-only signup in 5 lines
56
123
 
57
124
  ```ts
@@ -90,6 +157,18 @@ export function postgresStore(
90
157
  pool: { query(sql: string, params?: unknown[]): Promise<{ rows: any[] }> },
91
158
  opts?: { prefix?: string },
92
159
  ): AffiliateStore;
160
+ export function supabaseStore(
161
+ client: { rpc(fn: string, args?: Record<string, unknown>): PromiseLike<{ data: any; error: any }> },
162
+ opts?: { prefix?: string },
163
+ ): AffiliateStore;
164
+ export function supabaseMigrationSql(prefix?: string): string; // = migrations/supabase.sql for 'aff_'
165
+
166
+ export const REF_COOKIE = 'referral_code'; // @profullstack/referrals' cookie
167
+ export const REF_PARAM = 'ref';
168
+ export function refFromRequest(
169
+ req: Request | { headers: Headers; url: string },
170
+ opts?: { param?: string; cookieName?: string },
171
+ ): string | null; // ?ref= first, then the cookie
93
172
 
94
173
  export class Affiliates {
95
174
  constructor(store: AffiliateStore, opts?: {
@@ -97,6 +176,7 @@ export class Affiliates {
97
176
  defaultQuota?: number; // default 3
98
177
  inviteTtlMs?: number | null; // default 30 days, null = never expires
99
178
  split?: ReferralSplit; // default DEFAULT_SPLIT from @profullstack/referrals
179
+ externalCodes?: ReferralStore | ((code: string) => boolean | Promise<boolean>);
100
180
  });
101
181
  quota(userId: string): Promise<{ total: number; used: number; left: number }>;
102
182
  setQuota(userId: string, total: number): Promise<void>;
@@ -107,7 +187,8 @@ export class Affiliates {
107
187
  invites(inviterId: string): Promise<Omit<Invite, 'code'>[]>; // newest first; also carries revokedAt
108
188
  revokeInvite(inviteId: string): Promise<void>;
109
189
  affiliateCode(userId: string): Promise<string>;
110
- attribute(code: string, newUserId: string): Promise<{ ok: boolean; affiliateId?: string }>;
190
+ attribute(code: string, newUserId: string): Promise<{ ok: boolean; affiliateId?: string; external?: boolean }>;
191
+ isExternalCode(code: string): Promise<boolean>;
111
192
  affiliateOf(userId: string): Promise<string | null>;
112
193
  commission(amountCents: number): { discountCents: number; commissionCents: number };
113
194
  }
@@ -130,7 +211,8 @@ Semantics worth knowing:
130
211
  `O` to `0` and `I`/`L` to `1`. Invite codes come back as `INV-XXXX-XXXX-XXXX` (the `INV` prefix is
131
212
  optional on input), so `inv 7k3m qx9d 2f8h` redeems.
132
213
  - **Affiliate attribution** is first touch: a second `attribute` for the same user returns
133
- `{ ok: false, affiliateId: <the original> }`. Users cannot attribute themselves.
214
+ `{ ok: false, affiliateId: <the original> }`. Users cannot attribute themselves. A code that is not
215
+ ours but belongs to the site's @profullstack/referrals setup returns `{ ok: false, external: true }`.
134
216
 
135
217
  Tables (prefix configurable): `aff_invites`, `aff_quotas`, `aff_codes`, `aff_attributions`.
136
218
 
@@ -157,7 +239,10 @@ command takes `--json`. Other options: `--prefix`, `--default-quota`, `--ttl-day
157
239
 
158
240
  ```sh
159
241
  npm test # node --experimental-strip-types --test (memory + node:sqlite)
160
- AFFILIATES_TEST_PG_URL=postgres://postgres:x@127.0.0.1:55432/postgres npm test # adds real Postgres
242
+ AFFILIATES_TEST_PG_URL=postgres://postgres:x@127.0.0.1:55432/postgres npm test # adds real Postgres + supabaseStore over rpc()
243
+ # adds real PostgREST too (see .github/workflows and test/setup-postgrest.ts):
244
+ # AFFILIATES_TEST_POSTGREST_URL=http://127.0.0.1:3000 AFFILIATES_TEST_POSTGREST_JWT_SECRET=... npm test
245
+ npm run gen:migration # rewrite migrations/supabase.sql after changing supabaseMigrationSql()
161
246
  npm run build # tsc to dist/
162
247
  ```
163
248
 
@@ -19,6 +19,16 @@ export type RedeemInviteResult = {
19
19
  ok: false;
20
20
  reason: 'invalid' | 'expired' | 'used' | 'self' | 'revoked';
21
21
  };
22
+ /** Result of `attribute`. `external: true` means the code belongs to the site's other referral system. */
23
+ export type AttributeResult = {
24
+ ok: boolean;
25
+ affiliateId?: string;
26
+ external?: boolean;
27
+ };
28
+ /** A check for codes owned by another referral system: a function, or an @profullstack/referrals ReferralStore. */
29
+ export type ExternalCodes = ((code: string) => boolean | Promise<boolean>) | {
30
+ getCode(code: string): Promise<unknown | null>;
31
+ };
22
32
  export interface AffiliatesOptions {
23
33
  /** Clock in ms. Default Date.now. */
24
34
  now?: () => number;
@@ -28,6 +38,13 @@ export interface AffiliatesOptions {
28
38
  inviteTtlMs?: number | null;
29
39
  /** Commission split passed to @profullstack/referrals. Default DEFAULT_SPLIT (60% affiliate, 20% customer). */
30
40
  split?: ReferralSplit;
41
+ /**
42
+ * Codes that belong to an existing referral system (sites already on @profullstack/referrals).
43
+ * When `attribute()` gets a code that is not ours and this says it is theirs, it returns
44
+ * `{ ok: false, external: true }` and records nothing, so the existing flow handles it.
45
+ * Default: the store's `isExternalCode` when it has one (supabaseStore checks `referral_codes`).
46
+ */
47
+ externalCodes?: ExternalCodes;
31
48
  }
32
49
  /** Scarce invite codes plus open affiliate links over any AffiliateStore. */
33
50
  export declare class Affiliates {
@@ -36,7 +53,10 @@ export declare class Affiliates {
36
53
  readonly defaultQuota: number;
37
54
  readonly inviteTtlMs: number | null;
38
55
  readonly split: ReferralSplit;
56
+ private readonly externalCodes;
39
57
  constructor(store: AffiliateStore, opts?: AffiliatesOptions);
58
+ /** True when `code` belongs to the site's other referral system (see `externalCodes`). */
59
+ isExternalCode(code: string): Promise<boolean>;
40
60
  private total;
41
61
  /** Invites a user may create, has created (revoked-before-use ones are refunded), and has left. */
42
62
  quota(userId: string): Promise<{
@@ -65,11 +85,12 @@ export declare class Affiliates {
65
85
  revokeInvite(inviteId: string): Promise<void>;
66
86
  /** The user's public affiliate code: stable, created on first call, unlimited use. */
67
87
  affiliateCode(userId: string): Promise<string>;
68
- /** First-touch attribution of a new user to the affiliate who owns `code`. Never overwrites. */
69
- attribute(code: string, newUserId: string): Promise<{
70
- ok: boolean;
71
- affiliateId?: string;
72
- }>;
88
+ /**
89
+ * First-touch attribution of a new user to the affiliate who owns `code`. Never overwrites.
90
+ * A code that is not ours but belongs to the site's existing referral system returns
91
+ * `{ ok: false, external: true }` and records nothing.
92
+ */
93
+ attribute(code: string, newUserId: string): Promise<AttributeResult>;
73
94
  /** The affiliate a user is attributed to, or null. */
74
95
  affiliateOf(userId: string): Promise<string | null>;
75
96
  /** Discount for the new customer and commission for the affiliate, via @profullstack/referrals. */
@@ -20,12 +20,29 @@ export class Affiliates {
20
20
  defaultQuota;
21
21
  inviteTtlMs;
22
22
  split;
23
+ externalCodes;
23
24
  constructor(store, opts = {}) {
24
25
  this.store = store;
25
26
  this.now = opts.now ?? Date.now;
26
27
  this.defaultQuota = opts.defaultQuota ?? 3;
27
28
  this.inviteTtlMs = opts.inviteTtlMs === undefined ? 30 * DAY : opts.inviteTtlMs;
28
29
  this.split = opts.split ?? DEFAULT_SPLIT;
30
+ this.externalCodes = opts.externalCodes;
31
+ }
32
+ /** True when `code` belongs to the site's other referral system (see `externalCodes`). */
33
+ async isExternalCode(code) {
34
+ const raw = String(code ?? '').trim();
35
+ if (!raw)
36
+ return false;
37
+ const ext = this.externalCodes;
38
+ if (typeof ext === 'function')
39
+ return Boolean(await ext(raw));
40
+ // @profullstack/referrals looks codes up upper-cased and trimmed (validateCode)
41
+ if (ext)
42
+ return (await ext.getCode(raw.toUpperCase())) != null;
43
+ if (this.store.isExternalCode)
44
+ return this.store.isExternalCode(raw);
45
+ return false;
29
46
  }
30
47
  async total(userId) {
31
48
  const q = await this.store.getQuota(userId);
@@ -131,13 +148,19 @@ export class Affiliates {
131
148
  }
132
149
  throw new Error('could not mint a unique affiliate code');
133
150
  }
134
- /** First-touch attribution of a new user to the affiliate who owns `code`. Never overwrites. */
151
+ /**
152
+ * First-touch attribution of a new user to the affiliate who owns `code`. Never overwrites.
153
+ * A code that is not ours but belongs to the site's existing referral system returns
154
+ * `{ ok: false, external: true }` and records nothing.
155
+ */
135
156
  async attribute(code, newUserId) {
136
157
  if (!code || !newUserId)
137
158
  return { ok: false };
138
159
  const norm = normalizeCode(code);
139
- const affiliateId = await this.store.affiliateByCode(norm);
140
- if (!affiliateId || affiliateId === newUserId)
160
+ const affiliateId = norm ? await this.store.affiliateByCode(norm) : null;
161
+ if (!affiliateId)
162
+ return (await this.isExternalCode(code)) ? { ok: false, external: true } : { ok: false };
163
+ if (affiliateId === newUserId)
141
164
  return { ok: false };
142
165
  const inserted = await this.store.attribute({ userId: newUserId, affiliateId, code: norm, at: this.now() });
143
166
  if (inserted)
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Affiliates } from './affiliates.js';
2
- export type { Invite, RedeemInviteResult, AffiliatesOptions } from './affiliates.js';
2
+ export type { Invite, RedeemInviteResult, AffiliatesOptions, AttributeResult, ExternalCodes } from './affiliates.js';
3
3
  export type { AffiliateStore, InviteRow, AttributionRow } from './store.js';
4
4
  export { memoryStore } from './memory.js';
5
5
  export { sqliteStore } from './sqlite.js';
@@ -7,6 +7,10 @@ export type { SqliteLike } from './sqlite.js';
7
7
  export { postgresStore } from './postgres.js';
8
8
  export type { PgLike } from './postgres.js';
9
9
  export type { SqlStoreOptions } from './sql.js';
10
+ export { supabaseStore, supabaseMigrationSql } from './supabase.js';
11
+ export type { SupabaseLike, SupabaseStoreOptions } from './supabase.js';
12
+ export { REF_COOKIE, REF_PARAM, refFromRequest } from './ref.js';
13
+ export type { RequestLike } from './ref.js';
10
14
  export { normalizeCode, generateInviteCode, hashCode } from './codes.js';
11
15
  export { buildReferralUrl, extractCode, calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
12
16
  export type { ReferralSplit } from '@profullstack/referrals';
package/dist/index.js CHANGED
@@ -2,5 +2,7 @@ export { Affiliates } from "./affiliates.js";
2
2
  export { memoryStore } from "./memory.js";
3
3
  export { sqliteStore } from "./sqlite.js";
4
4
  export { postgresStore } from "./postgres.js";
5
+ export { supabaseStore, supabaseMigrationSql } from "./supabase.js";
6
+ export { REF_COOKIE, REF_PARAM, refFromRequest } from "./ref.js";
5
7
  export { normalizeCode, generateInviteCode, hashCode } from "./codes.js";
6
8
  export { buildReferralUrl, extractCode, calculateReferral, DEFAULT_SPLIT } from '@profullstack/referrals';
package/dist/ref.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Cookie name used by @profullstack/referrals (`trackReferralCode` in its `next` adapter defaults
3
+ * `cookieName` to this; its React `ReferralProvider` uses the same string as its sessionStorage key).
4
+ * Sites already on @profullstack/referrals keep setting this one cookie; affiliates reads it and never
5
+ * sets a second one, so one sign-up is counted once.
6
+ */
7
+ export declare const REF_COOKIE = "referral_code";
8
+ /** Query parameter for affiliate links, the same default as @profullstack/referrals (`?ref=`). */
9
+ export declare const REF_PARAM = "ref";
10
+ export type RequestLike = Request | {
11
+ headers: Headers;
12
+ url: string;
13
+ };
14
+ /**
15
+ * The referral/affiliate code a request carries: `?ref=` on the URL first (the click being handled),
16
+ * otherwise the `referral_code` cookie that @profullstack/referrals' middleware set on an earlier
17
+ * visit. Returns null when neither is present. Pass the result to `aff.attribute(code, newUserId)`.
18
+ */
19
+ export declare function refFromRequest(req: RequestLike, opts?: {
20
+ param?: string;
21
+ cookieName?: string;
22
+ }): string | null;
package/dist/ref.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Cookie name used by @profullstack/referrals (`trackReferralCode` in its `next` adapter defaults
3
+ * `cookieName` to this; its React `ReferralProvider` uses the same string as its sessionStorage key).
4
+ * Sites already on @profullstack/referrals keep setting this one cookie; affiliates reads it and never
5
+ * sets a second one, so one sign-up is counted once.
6
+ */
7
+ export const REF_COOKIE = 'referral_code';
8
+ /** Query parameter for affiliate links, the same default as @profullstack/referrals (`?ref=`). */
9
+ export const REF_PARAM = 'ref';
10
+ function readCookie(header, name) {
11
+ if (!header)
12
+ return null;
13
+ for (const part of header.split(';')) {
14
+ const eq = part.indexOf('=');
15
+ if (eq < 0)
16
+ continue;
17
+ if (part.slice(0, eq).trim() !== name)
18
+ continue;
19
+ let v = part.slice(eq + 1).trim();
20
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2)
21
+ v = v.slice(1, -1);
22
+ try {
23
+ v = decodeURIComponent(v);
24
+ }
25
+ catch {
26
+ /* keep raw */
27
+ }
28
+ return v || null;
29
+ }
30
+ return null;
31
+ }
32
+ /**
33
+ * The referral/affiliate code a request carries: `?ref=` on the URL first (the click being handled),
34
+ * otherwise the `referral_code` cookie that @profullstack/referrals' middleware set on an earlier
35
+ * visit. Returns null when neither is present. Pass the result to `aff.attribute(code, newUserId)`.
36
+ */
37
+ export function refFromRequest(req, opts = {}) {
38
+ const param = opts.param ?? REF_PARAM;
39
+ const cookieName = opts.cookieName ?? REF_COOKIE;
40
+ try {
41
+ const q = new URL(req.url, 'http://localhost').searchParams.get(param)?.trim();
42
+ if (q)
43
+ return q;
44
+ }
45
+ catch {
46
+ /* unparseable url: fall through to the cookie */
47
+ }
48
+ return readCookie(req.headers.get('cookie'), cookieName)?.trim() || null;
49
+ }
package/dist/store.d.ts CHANGED
@@ -53,4 +53,9 @@ export interface AffiliateStore {
53
53
  /** First touch: insert only if the user has no attribution. True if inserted. */
54
54
  attribute(row: AttributionRow): Promise<boolean>;
55
55
  getAttribution(userId: string): Promise<AttributionRow | null>;
56
+ /**
57
+ * Optional: true when `code` is not one of ours but belongs to another referral system on the same
58
+ * site (for example an @profullstack/referrals `referral_codes` row). supabaseStore implements it.
59
+ */
60
+ isExternalCode?(code: string): Promise<boolean>;
56
61
  }
@@ -0,0 +1,31 @@
1
+ import type { AffiliateStore } from './store.js';
2
+ /**
3
+ * The one supabase-js call this store uses: `client.rpc(fn, args)`. A `SupabaseClient` from
4
+ * `@supabase/supabase-js` (v2) fits as is; so does any PostgREST client with the same shape.
5
+ * Use a service-role client: the functions are not executable by `anon` or `authenticated`.
6
+ */
7
+ export interface SupabaseLike {
8
+ rpc(fn: string, args?: Record<string, unknown>): PromiseLike<{
9
+ data: any;
10
+ error: any;
11
+ }>;
12
+ }
13
+ export interface SupabaseStoreOptions {
14
+ /** Table and function name prefix. Default `aff_`. Must match the prefix the migration was generated with. */
15
+ prefix?: string;
16
+ }
17
+ /**
18
+ * SQL for a Supabase migration: the four tables (same schema as `postgresStore`, so both stores can
19
+ * share them), RLS enabled with no policies (anon and authenticated keys see nothing), and one
20
+ * SECURITY DEFINER function per store operation with a pinned search_path, executable only by
21
+ * `service_role`. The scarce operations are atomic inside those functions: quota check + insert
22
+ * under a per-inviter advisory lock, single-use claim with `UPDATE ... WHERE used_by IS NULL`, and
23
+ * first-touch attribution with `ON CONFLICT DO NOTHING`. Idempotent: safe to run twice.
24
+ */
25
+ export declare function supabaseMigrationSql(prefix?: string): string;
26
+ /**
27
+ * Store over supabase-js / PostgREST (service-role client). Every operation is one `rpc()` call to a
28
+ * function from `supabaseMigrationSql()` (shipped as `migrations/supabase.sql`); apply it first.
29
+ * Also answers `isExternalCode` from @profullstack/referrals' `referral_codes` table when it exists.
30
+ */
31
+ export declare function supabaseStore(client: SupabaseLike, opts?: SupabaseStoreOptions): AffiliateStore;
@@ -0,0 +1,226 @@
1
+ import { ddl } from "./sql.js";
2
+ function checkPrefix(prefix) {
3
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix))
4
+ throw new Error(`invalid table prefix: ${prefix}`);
5
+ return prefix;
6
+ }
7
+ const num = (v) => (v === null || v === undefined ? null : Number(v));
8
+ const str = (v) => (v === null || v === undefined ? null : String(v));
9
+ function toInvite(r) {
10
+ return {
11
+ id: String(r.id),
12
+ codeHash: String(r.code_hash),
13
+ inviterId: String(r.inviter_id),
14
+ createdAt: Number(r.created_at),
15
+ expiresAt: num(r.expires_at),
16
+ usedBy: str(r.used_by),
17
+ usedAt: num(r.used_at),
18
+ note: r.note == null ? '' : String(r.note),
19
+ revokedAt: num(r.revoked_at),
20
+ };
21
+ }
22
+ /** PostgREST returns a set-returning function as an array and a scalar one as the bare value. */
23
+ const rows = (data) => (Array.isArray(data) ? data : data == null ? [] : [data]);
24
+ const scalar = (data) => (Array.isArray(data) ? (data[0] ?? null) : data);
25
+ const truthy = (v) => v === true || v === 't' || v === 'true' || v === 1;
26
+ /**
27
+ * SQL for a Supabase migration: the four tables (same schema as `postgresStore`, so both stores can
28
+ * share them), RLS enabled with no policies (anon and authenticated keys see nothing), and one
29
+ * SECURITY DEFINER function per store operation with a pinned search_path, executable only by
30
+ * `service_role`. The scarce operations are atomic inside those functions: quota check + insert
31
+ * under a per-inviter advisory lock, single-use claim with `UPDATE ... WHERE used_by IS NULL`, and
32
+ * first-touch attribution with `ON CONFLICT DO NOTHING`. Idempotent: safe to run twice.
33
+ */
34
+ export function supabaseMigrationSql(prefix = 'aff_') {
35
+ const p = checkPrefix(prefix);
36
+ const sigs = [];
37
+ const bodies = [];
38
+ const fn = (name, args, returns, body, lang = 'sql') => {
39
+ const types = args ? args.split(',').map((a) => a.trim().split(/\s+/).slice(1).join(' ')) : [];
40
+ sigs.push(`public.${p}${name}(${types.join(', ')})`);
41
+ bodies.push(`CREATE OR REPLACE FUNCTION public.${p}${name}(${args})
42
+ RETURNS ${returns}
43
+ LANGUAGE ${lang}
44
+ SECURITY DEFINER
45
+ SET search_path = public, pg_temp
46
+ AS $fn$
47
+ ${body.trim()}
48
+ $fn$;
49
+ `);
50
+ };
51
+ const T = `public.${p}invites`;
52
+ const COUNTED = `(revoked_at IS NULL OR used_by IS NOT NULL)`;
53
+ fn('get_quota', 'p_user TEXT', 'INTEGER', `SELECT total FROM public.${p}quotas WHERE user_id = p_user`);
54
+ fn('set_quota', 'p_user TEXT, p_total INTEGER', 'VOID', `INSERT INTO public.${p}quotas (user_id, total) VALUES (p_user, p_total)
55
+ ON CONFLICT (user_id) DO UPDATE SET total = excluded.total`);
56
+ fn('count_invites', 'p_inviter TEXT', 'INTEGER', `SELECT count(*)::INTEGER FROM ${T} WHERE inviter_id = p_inviter AND ${COUNTED}`);
57
+ // Same logic and signature as postgresStore's insert_invite(), so the two stores can share a schema.
58
+ fn('insert_invite', 'p_id TEXT, p_hash TEXT, p_inviter TEXT, p_created BIGINT, p_expires BIGINT, p_note TEXT, p_limit INTEGER', 'BOOLEAN', `BEGIN
59
+ -- Per-inviter lock; plpgsql takes a fresh snapshot per statement, so the count sees every committed invite.
60
+ PERFORM pg_advisory_xact_lock(hashtext('${p}invites'), hashtext(p_inviter));
61
+ IF (SELECT count(*) FROM ${T} WHERE inviter_id = p_inviter AND ${COUNTED}) >= p_limit THEN
62
+ RETURN FALSE;
63
+ END IF;
64
+ INSERT INTO ${T} (id, code_hash, inviter_id, created_at, expires_at, note)
65
+ VALUES (p_id, p_hash, p_inviter, p_created, p_expires, coalesce(p_note, ''));
66
+ RETURN TRUE;
67
+ END`, 'plpgsql');
68
+ fn('get_invite_by_hash', 'p_hash TEXT', `SETOF ${T}`, `SELECT * FROM ${T} WHERE code_hash = p_hash`);
69
+ fn('get_invite', 'p_id TEXT', `SETOF ${T}`, `SELECT * FROM ${T} WHERE id = p_id`);
70
+ fn('claim_invite', 'p_id TEXT, p_user TEXT, p_at BIGINT', 'BOOLEAN', `WITH won AS (
71
+ UPDATE ${T} SET used_by = p_user, used_at = p_at
72
+ WHERE id = p_id AND used_by IS NULL AND revoked_at IS NULL
73
+ RETURNING 1
74
+ )
75
+ SELECT EXISTS (SELECT 1 FROM won)`);
76
+ fn('revoke_invite', 'p_id TEXT, p_at BIGINT', 'VOID', `UPDATE ${T} SET revoked_at = p_at WHERE id = p_id AND revoked_at IS NULL`);
77
+ fn('list_invites', 'p_inviter TEXT', `SETOF ${T}`, `SELECT * FROM ${T} WHERE inviter_id = p_inviter ORDER BY created_at DESC, id`);
78
+ fn('invited_by', 'p_user TEXT', 'TEXT', `SELECT inviter_id FROM ${T} WHERE used_by = p_user ORDER BY used_at ASC LIMIT 1`);
79
+ fn('invitees', 'p_inviter TEXT', 'TABLE (user_id TEXT, at BIGINT)', `SELECT used_by, used_at FROM ${T}
80
+ WHERE inviter_id = p_inviter AND used_by IS NOT NULL ORDER BY used_at ASC`);
81
+ fn('get_affiliate_code', 'p_user TEXT', 'TEXT', `SELECT code FROM public.${p}codes WHERE user_id = p_user`);
82
+ fn('put_affiliate_code', 'p_user TEXT, p_code TEXT, p_at BIGINT', 'TEXT', `BEGIN
83
+ INSERT INTO public.${p}codes (user_id, code, created_at) VALUES (p_user, p_code, p_at) ON CONFLICT DO NOTHING;
84
+ RETURN (SELECT code FROM public.${p}codes WHERE user_id = p_user);
85
+ END`, 'plpgsql');
86
+ fn('affiliate_by_code', 'p_code TEXT', 'TEXT', `SELECT user_id FROM public.${p}codes WHERE code = p_code`);
87
+ fn('attribute', 'p_user TEXT, p_affiliate TEXT, p_code TEXT, p_at BIGINT', 'BOOLEAN', `WITH ins AS (
88
+ INSERT INTO public.${p}attributions (user_id, affiliate_id, code, at) VALUES (p_user, p_affiliate, p_code, p_at)
89
+ ON CONFLICT (user_id) DO NOTHING
90
+ RETURNING 1
91
+ )
92
+ SELECT EXISTS (SELECT 1 FROM ins)`);
93
+ fn('get_attribution', 'p_user TEXT', `SETOF public.${p}attributions`, `SELECT * FROM public.${p}attributions WHERE user_id = p_user`);
94
+ // True when the code is an @profullstack/referrals code: a row in its `referral_codes` table
95
+ // (20260602000000_referrals.sql, codes stored upper-case). False when that table does not exist.
96
+ fn('is_external_code', 'p_code TEXT', 'BOOLEAN', `DECLARE found BOOLEAN := FALSE;
97
+ BEGIN
98
+ IF to_regclass('public.referral_codes') IS NULL THEN
99
+ RETURN FALSE;
100
+ END IF;
101
+ EXECUTE 'SELECT EXISTS (SELECT 1 FROM public.referral_codes WHERE code = $1)'
102
+ INTO found USING upper(btrim(p_code));
103
+ RETURN found;
104
+ END`, 'plpgsql');
105
+ const tables = ddl(p, 'sqlite').map((s) => s.replace(/CREATE (TABLE|INDEX) IF NOT EXISTS (\w+)/, (_m, kind, name) => kind === 'TABLE' ? `CREATE TABLE IF NOT EXISTS public.${name}` : `CREATE INDEX IF NOT EXISTS ${name}`).replace(/ ON (\w+) \(/, ' ON public.$1 (').replace(/\n\s+\)$/, '\n)').replace(/\n\s+/g, '\n ') + ';');
106
+ const names = ['invites', 'quotas', 'codes', 'attributions'];
107
+ const each = (role, stmt) => ` IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN\n${sigs.map((s) => ` ${stmt(s)}`).join('\n')}\n END IF;`;
108
+ return [
109
+ `-- @profullstack/affiliates: Supabase migration (prefix ${p})`,
110
+ `-- Generated by supabaseMigrationSql('${p}'). Same tables as postgresStore, so both stores can share them.`,
111
+ `-- Use with supabaseStore(serviceRoleClient). Idempotent.`,
112
+ '',
113
+ ...tables,
114
+ '',
115
+ '-- RLS on with no policies: anon and authenticated keys see nothing. Access goes only through the',
116
+ '-- SECURITY DEFINER functions below, which only service_role may execute.',
117
+ ...names.map((n) => `ALTER TABLE public.${p}${n} ENABLE ROW LEVEL SECURITY;`),
118
+ '',
119
+ ...bodies,
120
+ ...sigs.map((s) => `REVOKE ALL ON FUNCTION ${s} FROM PUBLIC;`),
121
+ '',
122
+ `DO $grants$
123
+ BEGIN
124
+ ${each('anon', (s) => `REVOKE ALL ON FUNCTION ${s} FROM anon;`)}
125
+ ${each('authenticated', (s) => `REVOKE ALL ON FUNCTION ${s} FROM authenticated;`)}
126
+ ${each('service_role', (s) => `GRANT EXECUTE ON FUNCTION ${s} TO service_role;`)}
127
+ ${['anon', 'authenticated'].map((role) => ` IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN\n${names.map((n) => ` REVOKE ALL ON TABLE public.${p}${n} FROM ${role};`).join('\n')}\n END IF;`).join('\n')}
128
+ END
129
+ $grants$;`,
130
+ '',
131
+ "NOTIFY pgrst, 'reload schema';",
132
+ '',
133
+ ].join('\n');
134
+ }
135
+ /**
136
+ * Store over supabase-js / PostgREST (service-role client). Every operation is one `rpc()` call to a
137
+ * function from `supabaseMigrationSql()` (shipped as `migrations/supabase.sql`); apply it first.
138
+ * Also answers `isExternalCode` from @profullstack/referrals' `referral_codes` table when it exists.
139
+ */
140
+ export function supabaseStore(client, opts = {}) {
141
+ const p = checkPrefix(opts.prefix ?? 'aff_');
142
+ const call = async (name, args) => {
143
+ const { data, error } = await client.rpc(`${p}${name}`, args);
144
+ if (error) {
145
+ const msg = typeof error === 'string' ? error : (error.message ?? JSON.stringify(error));
146
+ throw new Error(`${p}${name}: ${msg}`, { cause: error });
147
+ }
148
+ return data;
149
+ };
150
+ return {
151
+ async getQuota(userId) {
152
+ return num(scalar(await call('get_quota', { p_user: userId })));
153
+ },
154
+ async setQuota(userId, total) {
155
+ await call('set_quota', { p_user: userId, p_total: total });
156
+ },
157
+ async countInvites(inviterId) {
158
+ return Number(scalar(await call('count_invites', { p_inviter: inviterId })) ?? 0);
159
+ },
160
+ async insertInvite(row, limit) {
161
+ const ok = await call('insert_invite', {
162
+ p_id: row.id,
163
+ p_hash: row.codeHash,
164
+ p_inviter: row.inviterId,
165
+ p_created: row.createdAt,
166
+ p_expires: row.expiresAt,
167
+ p_note: row.note,
168
+ p_limit: limit,
169
+ });
170
+ return truthy(scalar(ok));
171
+ },
172
+ async getInviteByHash(codeHash) {
173
+ const r = rows(await call('get_invite_by_hash', { p_hash: codeHash }))[0];
174
+ return r ? toInvite(r) : null;
175
+ },
176
+ async getInvite(id) {
177
+ const r = rows(await call('get_invite', { p_id: id }))[0];
178
+ return r ? toInvite(r) : null;
179
+ },
180
+ async claimInvite(id, userId, at) {
181
+ return truthy(scalar(await call('claim_invite', { p_id: id, p_user: userId, p_at: at })));
182
+ },
183
+ async revokeInvite(id, at) {
184
+ await call('revoke_invite', { p_id: id, p_at: at });
185
+ },
186
+ async listInvites(inviterId) {
187
+ return rows(await call('list_invites', { p_inviter: inviterId })).map(toInvite);
188
+ },
189
+ async invitedBy(userId) {
190
+ return str(scalar(await call('invited_by', { p_user: userId })));
191
+ },
192
+ async invitees(inviterId) {
193
+ return rows(await call('invitees', { p_inviter: inviterId })).map((r) => ({
194
+ userId: String(r.user_id),
195
+ at: Number(r.at),
196
+ }));
197
+ },
198
+ async getAffiliateCode(userId) {
199
+ return str(scalar(await call('get_affiliate_code', { p_user: userId })));
200
+ },
201
+ async putAffiliateCode(userId, code, at) {
202
+ return str(scalar(await call('put_affiliate_code', { p_user: userId, p_code: code, p_at: at })));
203
+ },
204
+ async affiliateByCode(code) {
205
+ return str(scalar(await call('affiliate_by_code', { p_code: code })));
206
+ },
207
+ async attribute(row) {
208
+ const ok = await call('attribute', {
209
+ p_user: row.userId,
210
+ p_affiliate: row.affiliateId,
211
+ p_code: row.code,
212
+ p_at: row.at,
213
+ });
214
+ return truthy(scalar(ok));
215
+ },
216
+ async getAttribution(userId) {
217
+ const r = rows(await call('get_attribution', { p_user: userId }))[0];
218
+ return r
219
+ ? { userId: String(r.user_id), affiliateId: String(r.affiliate_id), code: String(r.code), at: Number(r.at) }
220
+ : null;
221
+ },
222
+ async isExternalCode(code) {
223
+ return truthy(scalar(await call('is_external_code', { p_code: code })));
224
+ },
225
+ };
226
+ }
@@ -0,0 +1,319 @@
1
+ -- @profullstack/affiliates: Supabase migration (prefix aff_)
2
+ -- Generated by supabaseMigrationSql('aff_'). Same tables as postgresStore, so both stores can share them.
3
+ -- Use with supabaseStore(serviceRoleClient). Idempotent.
4
+
5
+ CREATE TABLE IF NOT EXISTS public.aff_invites (
6
+ id TEXT PRIMARY KEY,
7
+ code_hash TEXT NOT NULL UNIQUE,
8
+ inviter_id TEXT NOT NULL,
9
+ created_at BIGINT NOT NULL,
10
+ expires_at BIGINT,
11
+ used_by TEXT,
12
+ used_at BIGINT,
13
+ note TEXT NOT NULL DEFAULT '',
14
+ revoked_at BIGINT
15
+ );
16
+ CREATE INDEX IF NOT EXISTS aff_invites_inviter_idx ON public.aff_invites (inviter_id);
17
+ CREATE INDEX IF NOT EXISTS aff_invites_used_by_idx ON public.aff_invites (used_by);
18
+ CREATE TABLE IF NOT EXISTS public.aff_quotas (
19
+ user_id TEXT PRIMARY KEY,
20
+ total INTEGER NOT NULL
21
+ );
22
+ CREATE TABLE IF NOT EXISTS public.aff_codes (
23
+ user_id TEXT PRIMARY KEY,
24
+ code TEXT NOT NULL UNIQUE,
25
+ created_at BIGINT NOT NULL
26
+ );
27
+ CREATE TABLE IF NOT EXISTS public.aff_attributions (
28
+ user_id TEXT PRIMARY KEY,
29
+ affiliate_id TEXT NOT NULL,
30
+ code TEXT NOT NULL,
31
+ at BIGINT NOT NULL
32
+ );
33
+ CREATE INDEX IF NOT EXISTS aff_attributions_affiliate_idx ON public.aff_attributions (affiliate_id);
34
+
35
+ -- RLS on with no policies: anon and authenticated keys see nothing. Access goes only through the
36
+ -- SECURITY DEFINER functions below, which only service_role may execute.
37
+ ALTER TABLE public.aff_invites ENABLE ROW LEVEL SECURITY;
38
+ ALTER TABLE public.aff_quotas ENABLE ROW LEVEL SECURITY;
39
+ ALTER TABLE public.aff_codes ENABLE ROW LEVEL SECURITY;
40
+ ALTER TABLE public.aff_attributions ENABLE ROW LEVEL SECURITY;
41
+
42
+ CREATE OR REPLACE FUNCTION public.aff_get_quota(p_user TEXT)
43
+ RETURNS INTEGER
44
+ LANGUAGE sql
45
+ SECURITY DEFINER
46
+ SET search_path = public, pg_temp
47
+ AS $fn$
48
+ SELECT total FROM public.aff_quotas WHERE user_id = p_user
49
+ $fn$;
50
+
51
+ CREATE OR REPLACE FUNCTION public.aff_set_quota(p_user TEXT, p_total INTEGER)
52
+ RETURNS VOID
53
+ LANGUAGE sql
54
+ SECURITY DEFINER
55
+ SET search_path = public, pg_temp
56
+ AS $fn$
57
+ INSERT INTO public.aff_quotas (user_id, total) VALUES (p_user, p_total)
58
+ ON CONFLICT (user_id) DO UPDATE SET total = excluded.total
59
+ $fn$;
60
+
61
+ CREATE OR REPLACE FUNCTION public.aff_count_invites(p_inviter TEXT)
62
+ RETURNS INTEGER
63
+ LANGUAGE sql
64
+ SECURITY DEFINER
65
+ SET search_path = public, pg_temp
66
+ AS $fn$
67
+ SELECT count(*)::INTEGER FROM public.aff_invites WHERE inviter_id = p_inviter AND (revoked_at IS NULL OR used_by IS NOT NULL)
68
+ $fn$;
69
+
70
+ CREATE OR REPLACE FUNCTION public.aff_insert_invite(p_id TEXT, p_hash TEXT, p_inviter TEXT, p_created BIGINT, p_expires BIGINT, p_note TEXT, p_limit INTEGER)
71
+ RETURNS BOOLEAN
72
+ LANGUAGE plpgsql
73
+ SECURITY DEFINER
74
+ SET search_path = public, pg_temp
75
+ AS $fn$
76
+ BEGIN
77
+ -- Per-inviter lock; plpgsql takes a fresh snapshot per statement, so the count sees every committed invite.
78
+ PERFORM pg_advisory_xact_lock(hashtext('aff_invites'), hashtext(p_inviter));
79
+ IF (SELECT count(*) FROM public.aff_invites WHERE inviter_id = p_inviter AND (revoked_at IS NULL OR used_by IS NOT NULL)) >= p_limit THEN
80
+ RETURN FALSE;
81
+ END IF;
82
+ INSERT INTO public.aff_invites (id, code_hash, inviter_id, created_at, expires_at, note)
83
+ VALUES (p_id, p_hash, p_inviter, p_created, p_expires, coalesce(p_note, ''));
84
+ RETURN TRUE;
85
+ END
86
+ $fn$;
87
+
88
+ CREATE OR REPLACE FUNCTION public.aff_get_invite_by_hash(p_hash TEXT)
89
+ RETURNS SETOF public.aff_invites
90
+ LANGUAGE sql
91
+ SECURITY DEFINER
92
+ SET search_path = public, pg_temp
93
+ AS $fn$
94
+ SELECT * FROM public.aff_invites WHERE code_hash = p_hash
95
+ $fn$;
96
+
97
+ CREATE OR REPLACE FUNCTION public.aff_get_invite(p_id TEXT)
98
+ RETURNS SETOF public.aff_invites
99
+ LANGUAGE sql
100
+ SECURITY DEFINER
101
+ SET search_path = public, pg_temp
102
+ AS $fn$
103
+ SELECT * FROM public.aff_invites WHERE id = p_id
104
+ $fn$;
105
+
106
+ CREATE OR REPLACE FUNCTION public.aff_claim_invite(p_id TEXT, p_user TEXT, p_at BIGINT)
107
+ RETURNS BOOLEAN
108
+ LANGUAGE sql
109
+ SECURITY DEFINER
110
+ SET search_path = public, pg_temp
111
+ AS $fn$
112
+ WITH won AS (
113
+ UPDATE public.aff_invites SET used_by = p_user, used_at = p_at
114
+ WHERE id = p_id AND used_by IS NULL AND revoked_at IS NULL
115
+ RETURNING 1
116
+ )
117
+ SELECT EXISTS (SELECT 1 FROM won)
118
+ $fn$;
119
+
120
+ CREATE OR REPLACE FUNCTION public.aff_revoke_invite(p_id TEXT, p_at BIGINT)
121
+ RETURNS VOID
122
+ LANGUAGE sql
123
+ SECURITY DEFINER
124
+ SET search_path = public, pg_temp
125
+ AS $fn$
126
+ UPDATE public.aff_invites SET revoked_at = p_at WHERE id = p_id AND revoked_at IS NULL
127
+ $fn$;
128
+
129
+ CREATE OR REPLACE FUNCTION public.aff_list_invites(p_inviter TEXT)
130
+ RETURNS SETOF public.aff_invites
131
+ LANGUAGE sql
132
+ SECURITY DEFINER
133
+ SET search_path = public, pg_temp
134
+ AS $fn$
135
+ SELECT * FROM public.aff_invites WHERE inviter_id = p_inviter ORDER BY created_at DESC, id
136
+ $fn$;
137
+
138
+ CREATE OR REPLACE FUNCTION public.aff_invited_by(p_user TEXT)
139
+ RETURNS TEXT
140
+ LANGUAGE sql
141
+ SECURITY DEFINER
142
+ SET search_path = public, pg_temp
143
+ AS $fn$
144
+ SELECT inviter_id FROM public.aff_invites WHERE used_by = p_user ORDER BY used_at ASC LIMIT 1
145
+ $fn$;
146
+
147
+ CREATE OR REPLACE FUNCTION public.aff_invitees(p_inviter TEXT)
148
+ RETURNS TABLE (user_id TEXT, at BIGINT)
149
+ LANGUAGE sql
150
+ SECURITY DEFINER
151
+ SET search_path = public, pg_temp
152
+ AS $fn$
153
+ SELECT used_by, used_at FROM public.aff_invites
154
+ WHERE inviter_id = p_inviter AND used_by IS NOT NULL ORDER BY used_at ASC
155
+ $fn$;
156
+
157
+ CREATE OR REPLACE FUNCTION public.aff_get_affiliate_code(p_user TEXT)
158
+ RETURNS TEXT
159
+ LANGUAGE sql
160
+ SECURITY DEFINER
161
+ SET search_path = public, pg_temp
162
+ AS $fn$
163
+ SELECT code FROM public.aff_codes WHERE user_id = p_user
164
+ $fn$;
165
+
166
+ CREATE OR REPLACE FUNCTION public.aff_put_affiliate_code(p_user TEXT, p_code TEXT, p_at BIGINT)
167
+ RETURNS TEXT
168
+ LANGUAGE plpgsql
169
+ SECURITY DEFINER
170
+ SET search_path = public, pg_temp
171
+ AS $fn$
172
+ BEGIN
173
+ INSERT INTO public.aff_codes (user_id, code, created_at) VALUES (p_user, p_code, p_at) ON CONFLICT DO NOTHING;
174
+ RETURN (SELECT code FROM public.aff_codes WHERE user_id = p_user);
175
+ END
176
+ $fn$;
177
+
178
+ CREATE OR REPLACE FUNCTION public.aff_affiliate_by_code(p_code TEXT)
179
+ RETURNS TEXT
180
+ LANGUAGE sql
181
+ SECURITY DEFINER
182
+ SET search_path = public, pg_temp
183
+ AS $fn$
184
+ SELECT user_id FROM public.aff_codes WHERE code = p_code
185
+ $fn$;
186
+
187
+ CREATE OR REPLACE FUNCTION public.aff_attribute(p_user TEXT, p_affiliate TEXT, p_code TEXT, p_at BIGINT)
188
+ RETURNS BOOLEAN
189
+ LANGUAGE sql
190
+ SECURITY DEFINER
191
+ SET search_path = public, pg_temp
192
+ AS $fn$
193
+ WITH ins AS (
194
+ INSERT INTO public.aff_attributions (user_id, affiliate_id, code, at) VALUES (p_user, p_affiliate, p_code, p_at)
195
+ ON CONFLICT (user_id) DO NOTHING
196
+ RETURNING 1
197
+ )
198
+ SELECT EXISTS (SELECT 1 FROM ins)
199
+ $fn$;
200
+
201
+ CREATE OR REPLACE FUNCTION public.aff_get_attribution(p_user TEXT)
202
+ RETURNS SETOF public.aff_attributions
203
+ LANGUAGE sql
204
+ SECURITY DEFINER
205
+ SET search_path = public, pg_temp
206
+ AS $fn$
207
+ SELECT * FROM public.aff_attributions WHERE user_id = p_user
208
+ $fn$;
209
+
210
+ CREATE OR REPLACE FUNCTION public.aff_is_external_code(p_code TEXT)
211
+ RETURNS BOOLEAN
212
+ LANGUAGE plpgsql
213
+ SECURITY DEFINER
214
+ SET search_path = public, pg_temp
215
+ AS $fn$
216
+ DECLARE found BOOLEAN := FALSE;
217
+ BEGIN
218
+ IF to_regclass('public.referral_codes') IS NULL THEN
219
+ RETURN FALSE;
220
+ END IF;
221
+ EXECUTE 'SELECT EXISTS (SELECT 1 FROM public.referral_codes WHERE code = $1)'
222
+ INTO found USING upper(btrim(p_code));
223
+ RETURN found;
224
+ END
225
+ $fn$;
226
+
227
+ REVOKE ALL ON FUNCTION public.aff_get_quota(TEXT) FROM PUBLIC;
228
+ REVOKE ALL ON FUNCTION public.aff_set_quota(TEXT, INTEGER) FROM PUBLIC;
229
+ REVOKE ALL ON FUNCTION public.aff_count_invites(TEXT) FROM PUBLIC;
230
+ REVOKE ALL ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) FROM PUBLIC;
231
+ REVOKE ALL ON FUNCTION public.aff_get_invite_by_hash(TEXT) FROM PUBLIC;
232
+ REVOKE ALL ON FUNCTION public.aff_get_invite(TEXT) FROM PUBLIC;
233
+ REVOKE ALL ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) FROM PUBLIC;
234
+ REVOKE ALL ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) FROM PUBLIC;
235
+ REVOKE ALL ON FUNCTION public.aff_list_invites(TEXT) FROM PUBLIC;
236
+ REVOKE ALL ON FUNCTION public.aff_invited_by(TEXT) FROM PUBLIC;
237
+ REVOKE ALL ON FUNCTION public.aff_invitees(TEXT) FROM PUBLIC;
238
+ REVOKE ALL ON FUNCTION public.aff_get_affiliate_code(TEXT) FROM PUBLIC;
239
+ REVOKE ALL ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) FROM PUBLIC;
240
+ REVOKE ALL ON FUNCTION public.aff_affiliate_by_code(TEXT) FROM PUBLIC;
241
+ REVOKE ALL ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) FROM PUBLIC;
242
+ REVOKE ALL ON FUNCTION public.aff_get_attribution(TEXT) FROM PUBLIC;
243
+ REVOKE ALL ON FUNCTION public.aff_is_external_code(TEXT) FROM PUBLIC;
244
+
245
+ DO $grants$
246
+ BEGIN
247
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
248
+ REVOKE ALL ON FUNCTION public.aff_get_quota(TEXT) FROM anon;
249
+ REVOKE ALL ON FUNCTION public.aff_set_quota(TEXT, INTEGER) FROM anon;
250
+ REVOKE ALL ON FUNCTION public.aff_count_invites(TEXT) FROM anon;
251
+ REVOKE ALL ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) FROM anon;
252
+ REVOKE ALL ON FUNCTION public.aff_get_invite_by_hash(TEXT) FROM anon;
253
+ REVOKE ALL ON FUNCTION public.aff_get_invite(TEXT) FROM anon;
254
+ REVOKE ALL ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) FROM anon;
255
+ REVOKE ALL ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) FROM anon;
256
+ REVOKE ALL ON FUNCTION public.aff_list_invites(TEXT) FROM anon;
257
+ REVOKE ALL ON FUNCTION public.aff_invited_by(TEXT) FROM anon;
258
+ REVOKE ALL ON FUNCTION public.aff_invitees(TEXT) FROM anon;
259
+ REVOKE ALL ON FUNCTION public.aff_get_affiliate_code(TEXT) FROM anon;
260
+ REVOKE ALL ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) FROM anon;
261
+ REVOKE ALL ON FUNCTION public.aff_affiliate_by_code(TEXT) FROM anon;
262
+ REVOKE ALL ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) FROM anon;
263
+ REVOKE ALL ON FUNCTION public.aff_get_attribution(TEXT) FROM anon;
264
+ REVOKE ALL ON FUNCTION public.aff_is_external_code(TEXT) FROM anon;
265
+ END IF;
266
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
267
+ REVOKE ALL ON FUNCTION public.aff_get_quota(TEXT) FROM authenticated;
268
+ REVOKE ALL ON FUNCTION public.aff_set_quota(TEXT, INTEGER) FROM authenticated;
269
+ REVOKE ALL ON FUNCTION public.aff_count_invites(TEXT) FROM authenticated;
270
+ REVOKE ALL ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) FROM authenticated;
271
+ REVOKE ALL ON FUNCTION public.aff_get_invite_by_hash(TEXT) FROM authenticated;
272
+ REVOKE ALL ON FUNCTION public.aff_get_invite(TEXT) FROM authenticated;
273
+ REVOKE ALL ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) FROM authenticated;
274
+ REVOKE ALL ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) FROM authenticated;
275
+ REVOKE ALL ON FUNCTION public.aff_list_invites(TEXT) FROM authenticated;
276
+ REVOKE ALL ON FUNCTION public.aff_invited_by(TEXT) FROM authenticated;
277
+ REVOKE ALL ON FUNCTION public.aff_invitees(TEXT) FROM authenticated;
278
+ REVOKE ALL ON FUNCTION public.aff_get_affiliate_code(TEXT) FROM authenticated;
279
+ REVOKE ALL ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) FROM authenticated;
280
+ REVOKE ALL ON FUNCTION public.aff_affiliate_by_code(TEXT) FROM authenticated;
281
+ REVOKE ALL ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) FROM authenticated;
282
+ REVOKE ALL ON FUNCTION public.aff_get_attribution(TEXT) FROM authenticated;
283
+ REVOKE ALL ON FUNCTION public.aff_is_external_code(TEXT) FROM authenticated;
284
+ END IF;
285
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
286
+ GRANT EXECUTE ON FUNCTION public.aff_get_quota(TEXT) TO service_role;
287
+ GRANT EXECUTE ON FUNCTION public.aff_set_quota(TEXT, INTEGER) TO service_role;
288
+ GRANT EXECUTE ON FUNCTION public.aff_count_invites(TEXT) TO service_role;
289
+ GRANT EXECUTE ON FUNCTION public.aff_insert_invite(TEXT, TEXT, TEXT, BIGINT, BIGINT, TEXT, INTEGER) TO service_role;
290
+ GRANT EXECUTE ON FUNCTION public.aff_get_invite_by_hash(TEXT) TO service_role;
291
+ GRANT EXECUTE ON FUNCTION public.aff_get_invite(TEXT) TO service_role;
292
+ GRANT EXECUTE ON FUNCTION public.aff_claim_invite(TEXT, TEXT, BIGINT) TO service_role;
293
+ GRANT EXECUTE ON FUNCTION public.aff_revoke_invite(TEXT, BIGINT) TO service_role;
294
+ GRANT EXECUTE ON FUNCTION public.aff_list_invites(TEXT) TO service_role;
295
+ GRANT EXECUTE ON FUNCTION public.aff_invited_by(TEXT) TO service_role;
296
+ GRANT EXECUTE ON FUNCTION public.aff_invitees(TEXT) TO service_role;
297
+ GRANT EXECUTE ON FUNCTION public.aff_get_affiliate_code(TEXT) TO service_role;
298
+ GRANT EXECUTE ON FUNCTION public.aff_put_affiliate_code(TEXT, TEXT, BIGINT) TO service_role;
299
+ GRANT EXECUTE ON FUNCTION public.aff_affiliate_by_code(TEXT) TO service_role;
300
+ GRANT EXECUTE ON FUNCTION public.aff_attribute(TEXT, TEXT, TEXT, BIGINT) TO service_role;
301
+ GRANT EXECUTE ON FUNCTION public.aff_get_attribution(TEXT) TO service_role;
302
+ GRANT EXECUTE ON FUNCTION public.aff_is_external_code(TEXT) TO service_role;
303
+ END IF;
304
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
305
+ REVOKE ALL ON TABLE public.aff_invites FROM anon;
306
+ REVOKE ALL ON TABLE public.aff_quotas FROM anon;
307
+ REVOKE ALL ON TABLE public.aff_codes FROM anon;
308
+ REVOKE ALL ON TABLE public.aff_attributions FROM anon;
309
+ END IF;
310
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
311
+ REVOKE ALL ON TABLE public.aff_invites FROM authenticated;
312
+ REVOKE ALL ON TABLE public.aff_quotas FROM authenticated;
313
+ REVOKE ALL ON TABLE public.aff_codes FROM authenticated;
314
+ REVOKE ALL ON TABLE public.aff_attributions FROM authenticated;
315
+ END IF;
316
+ END
317
+ $grants$;
318
+
319
+ NOTIFY pgrst, 'reload schema';
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@profullstack/affiliates",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
- "description": "Scarce invite codes (per-user quota, single use, who-invited-whom) plus open affiliate links with first-touch attribution. Commission math from @profullstack/referrals. Memory, SQLite (bun:sqlite, node:sqlite, better-sqlite3) and Postgres stores, plus a CLI.",
5
+ "description": "Scarce invite codes (per-user quota, single use, who-invited-whom) plus open affiliate links with first-touch attribution. Commission math and the referral_code cookie from @profullstack/referrals. Memory, SQLite (bun:sqlite, node:sqlite, better-sqlite3), Postgres and Supabase (supabase-js rpc) stores, plus a CLI.",
6
6
  "keywords": [
7
7
  "invite",
8
8
  "invite-codes",
@@ -12,6 +12,8 @@
12
12
  "attribution",
13
13
  "sqlite",
14
14
  "postgres",
15
+ "supabase",
16
+ "postgrest",
15
17
  "profullstack"
16
18
  ],
17
19
  "license": "MIT",
@@ -28,13 +30,15 @@
28
30
  "import": "./dist/index.js",
29
31
  "default": "./dist/index.js"
30
32
  },
31
- "./package.json": "./package.json"
33
+ "./package.json": "./package.json",
34
+ "./migrations/supabase.sql": "./migrations/supabase.sql"
32
35
  },
33
36
  "bin": {
34
37
  "affiliates": "./dist/cli.js"
35
38
  },
36
39
  "files": [
37
40
  "dist",
41
+ "migrations",
38
42
  "README.md",
39
43
  "LICENSE"
40
44
  ],
@@ -42,6 +46,7 @@
42
46
  "build": "rm -rf dist && tsc -p tsconfig.build.json && sed -i -E \"s#(from '\\./[a-z-]+)\\.ts'#\\1.js'#\" dist/*.d.ts && chmod +x dist/cli.js",
43
47
  "typecheck": "tsc --noEmit -p tsconfig.json",
44
48
  "test": "node --experimental-strip-types --no-warnings --test test/*.test.ts",
49
+ "gen:migration": "node --experimental-strip-types --no-warnings scripts/gen-migration.ts",
45
50
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
46
51
  },
47
52
  "dependencies": {