a tool for shared writing and social publishing

add publication subscription records and tables

+347 -20
+30 -2
appview/index.ts
··· 4 4 const idResolver = new IdResolver(); 5 5 import { Firehose, MemoryRunner } from "@atproto/sync"; 6 6 import { ids } from "lexicons/api/lexicons"; 7 - import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; 7 + import { 8 + PubLeafletDocument, 9 + PubLeafletPublication, 10 + PubLeafletPublicationSubscription, 11 + } from "lexicons/api"; 8 12 import { AtUri } from "@atproto/syntax"; 9 13 import { writeFile, readFile } from "fs/promises"; 10 14 ··· 31 35 excludeIdentity: true, 32 36 runner, 33 37 idResolver, 34 - filterCollections: [ids.PubLeafletDocument, ids.PubLeafletPublication], 38 + filterCollections: [ 39 + ids.PubLeafletDocument, 40 + ids.PubLeafletPublication, 41 + ids.PubLeafletPublicationSubscription, 42 + ], 35 43 handleEvent: async (evt) => { 36 44 if ( 37 45 evt.event == "account" || ··· 82 90 if (evt.event === "delete") { 83 91 await supabase 84 92 .from("publications") 93 + .delete() 94 + .eq("uri", evt.uri.toString()); 95 + } 96 + } 97 + if (evt.collection === ids.PubLeafletPublication) { 98 + if (evt.event === "create" || evt.event === "update") { 99 + let record = PubLeafletPublicationSubscription.validateRecord( 100 + evt.record, 101 + ); 102 + if (!record.success) return; 103 + await supabase.from("publication_subscriptions").upsert({ 104 + uri: evt.uri.toString(), 105 + identity: evt.did, 106 + publication: record.value.publication, 107 + record: record.value as Json, 108 + }); 109 + } 110 + if (evt.event === "delete") { 111 + await supabase 112 + .from("publication_subscriptions") 85 113 .delete() 86 114 .eq("uri", evt.uri.toString()); 87 115 }
+18 -10
drizzle/relations.ts
··· 1 1 import { relations } from "drizzle-orm/relations"; 2 - import { entities, facts, entity_sets, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, poll_votes_on_entity, subscribers_to_publications, publications, permission_token_on_homepage, documents, documents_in_publications, publication_domains, leaflets_in_publications, permission_token_rights } from "./schema"; 2 + import { entities, facts, entity_sets, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, custom_domains, phone_rsvps_to_entity, custom_domain_routes, poll_votes_on_entity, subscribers_to_publications, publications, permission_token_on_homepage, documents, documents_in_publications, publication_domains, publication_subscriptions, leaflets_in_publications, permission_token_rights } from "./schema"; 3 3 4 4 export const factsRelations = relations(facts, ({one}) => ({ 5 5 entity: one(entities, { ··· 78 78 }), 79 79 })); 80 80 81 + export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ 82 + identity: one(identities, { 83 + fields: [custom_domains.identity], 84 + references: [identities.email] 85 + }), 86 + custom_domain_routes: many(custom_domain_routes), 87 + publication_domains: many(publication_domains), 88 + })); 89 + 81 90 export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ 82 91 entity: one(entities, { 83 92 fields: [phone_rsvps_to_entity.entity], ··· 102 111 }), 103 112 })); 104 113 105 - export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ 106 - custom_domain_routes: many(custom_domain_routes), 107 - identity: one(identities, { 108 - fields: [custom_domains.identity], 109 - references: [identities.email] 110 - }), 111 - publication_domains: many(publication_domains), 112 - })); 113 - 114 114 export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({ 115 115 entity_option_entity: one(entities, { 116 116 fields: [poll_votes_on_entity.option_entity], ··· 139 139 subscribers_to_publications: many(subscribers_to_publications), 140 140 documents_in_publications: many(documents_in_publications), 141 141 publication_domains: many(publication_domains), 142 + publication_subscriptions: many(publication_subscriptions), 142 143 leaflets_in_publications: many(leaflets_in_publications), 143 144 })); 144 145 ··· 180 181 }), 181 182 publication: one(publications, { 182 183 fields: [publication_domains.publication], 184 + references: [publications.uri] 185 + }), 186 + })); 187 + 188 + export const publication_subscriptionsRelations = relations(publication_subscriptions, ({one}) => ({ 189 + publication: one(publications, { 190 + fields: [publication_subscriptions.publication], 183 191 references: [publications.uri] 184 192 }), 185 193 }));
+22 -8
drizzle/schema.ts
··· 115 115 country_code: text("country_code").notNull(), 116 116 }); 117 117 118 + export const custom_domains = pgTable("custom_domains", { 119 + domain: text("domain").primaryKey().notNull(), 120 + identity: text("identity").default('').references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), 121 + confirmed: boolean("confirmed").notNull(), 122 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 123 + }); 124 + 118 125 export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", { 119 126 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 120 127 phone_number: text("phone_number").notNull(), ··· 143 150 return { 144 151 custom_domain_routes_domain_route_key: unique("custom_domain_routes_domain_route_key").on(table.domain, table.route), 145 152 } 146 - }); 147 - 148 - export const custom_domains = pgTable("custom_domains", { 149 - domain: text("domain").primaryKey().notNull(), 150 - identity: text("identity").default('').references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), 151 - confirmed: boolean("confirmed").notNull(), 152 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 153 153 }); 154 154 155 155 export const poll_votes_on_entity = pgTable("poll_votes_on_entity", { ··· 205 205 } 206 206 }); 207 207 208 + export const publication_subscriptions = pgTable("publication_subscriptions", { 209 + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), 210 + identity: text("identity").notNull(), 211 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 212 + record: jsonb("record").notNull(), 213 + uri: text("uri"), 214 + }, 215 + (table) => { 216 + return { 217 + publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), 218 + publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), 219 + } 220 + }); 221 + 208 222 export const leaflets_in_publications = pgTable("leaflets_in_publications", { 209 223 publication: text("publication").notNull().references(() => publications.uri), 210 - doc: text("doc").default('').references(() => documents.uri), 224 + doc: text("doc").default('').references(() => documents.uri, { onDelete: "set null" } ), 211 225 leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id), 212 226 description: text("description").default('').notNull(), 213 227 title: text("title").default('').notNull(),
+79
lexicons/api/index.ts
··· 12 12 import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 13 13 import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 14 14 import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 15 + import * as PubLeafletPublicationSubscription from './types/pub/leaflet/publication/subscription' 15 16 import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 16 17 import * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' 17 18 import * as ComAtprotoRepoApplyWrites from './types/com/atproto/repo/applyWrites' ··· 34 35 export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' 35 36 export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' 36 37 export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' 38 + export * as PubLeafletPublicationSubscription from './types/pub/leaflet/publication/subscription' 37 39 export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' 38 40 export * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' 39 41 export * as ComAtprotoRepoApplyWrites from './types/com/atproto/repo/applyWrites' ··· 89 91 publication: PublicationRecord 90 92 blocks: PubLeafletBlocksNS 91 93 pages: PubLeafletPagesNS 94 + publication: PubLeafletPublicationNS 92 95 richtext: PubLeafletRichtextNS 93 96 94 97 constructor(client: XrpcClient) { 95 98 this._client = client 96 99 this.blocks = new PubLeafletBlocksNS(client) 97 100 this.pages = new PubLeafletPagesNS(client) 101 + this.publication = new PubLeafletPublicationNS(client) 98 102 this.richtext = new PubLeafletRichtextNS(client) 99 103 this.document = new DocumentRecord(client) 100 104 this.publication = new PublicationRecord(client) ··· 114 118 115 119 constructor(client: XrpcClient) { 116 120 this._client = client 121 + } 122 + } 123 + 124 + export class PubLeafletPublicationNS { 125 + _client: XrpcClient 126 + subscription: SubscriptionRecord 127 + 128 + constructor(client: XrpcClient) { 129 + this._client = client 130 + this.subscription = new SubscriptionRecord(client) 131 + } 132 + } 133 + 134 + export class SubscriptionRecord { 135 + _client: XrpcClient 136 + 137 + constructor(client: XrpcClient) { 138 + this._client = client 139 + } 140 + 141 + async list( 142 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 143 + ): Promise<{ 144 + cursor?: string 145 + records: { uri: string; value: PubLeafletPublicationSubscription.Record }[] 146 + }> { 147 + const res = await this._client.call('com.atproto.repo.listRecords', { 148 + collection: 'pub.leaflet.publication.subscription', 149 + ...params, 150 + }) 151 + return res.data 152 + } 153 + 154 + async get( 155 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 156 + ): Promise<{ 157 + uri: string 158 + cid: string 159 + value: PubLeafletPublicationSubscription.Record 160 + }> { 161 + const res = await this._client.call('com.atproto.repo.getRecord', { 162 + collection: 'pub.leaflet.publication.subscription', 163 + ...params, 164 + }) 165 + return res.data 166 + } 167 + 168 + async create( 169 + params: OmitKey< 170 + ComAtprotoRepoCreateRecord.InputSchema, 171 + 'collection' | 'record' 172 + >, 173 + record: Un$Typed<PubLeafletPublicationSubscription.Record>, 174 + headers?: Record<string, string>, 175 + ): Promise<{ uri: string; cid: string }> { 176 + const collection = 'pub.leaflet.publication.subscription' 177 + const res = await this._client.call( 178 + 'com.atproto.repo.createRecord', 179 + undefined, 180 + { collection, ...params, record: { ...record, $type: collection } }, 181 + { encoding: 'application/json', headers }, 182 + ) 183 + return res.data 184 + } 185 + 186 + async delete( 187 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 188 + headers?: Record<string, string>, 189 + ): Promise<void> { 190 + await this._client.call( 191 + 'com.atproto.repo.deleteRecord', 192 + undefined, 193 + { collection: 'pub.leaflet.publication.subscription', ...params }, 194 + { headers }, 195 + ) 117 196 } 118 197 } 119 198
+26
lexicons/api/lexicons.ts
··· 29 29 maxLength: 1280, 30 30 maxGraphemes: 128, 31 31 }, 32 + postRef: { 33 + type: 'ref', 34 + ref: 'lex:com.atproto.repo.strongRef', 35 + }, 32 36 description: { 33 37 type: 'string', 34 38 maxLength: 3000, ··· 266 270 }, 267 271 textAlignRight: { 268 272 type: 'token', 273 + }, 274 + }, 275 + }, 276 + PubLeafletPublicationSubscription: { 277 + lexicon: 1, 278 + id: 'pub.leaflet.publication.subscription', 279 + defs: { 280 + main: { 281 + type: 'record', 282 + key: 'tid', 283 + description: 'Record declaring a subscription to a publication', 284 + record: { 285 + type: 'object', 286 + required: ['publication'], 287 + properties: { 288 + publication: { 289 + type: 'string', 290 + format: 'at-uri', 291 + }, 292 + }, 293 + }, 269 294 }, 270 295 }, 271 296 }, ··· 1344 1369 PubLeafletBlocksText: 'pub.leaflet.blocks.text', 1345 1370 PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', 1346 1371 PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', 1372 + PubLeafletPublicationSubscription: 'pub.leaflet.publication.subscription', 1347 1373 PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', 1348 1374 ComAtprotoLabelDefs: 'com.atproto.label.defs', 1349 1375 ComAtprotoRepoApplyWrites: 'com.atproto.repo.applyWrites',
+2
lexicons/api/types/pub/leaflet/document.ts
··· 5 5 import { CID } from 'multiformats/cid' 6 6 import { validate as _validate } from '../../../lexicons' 7 7 import { $Typed, is$typed as _is$typed, OmitKey } from '../../../util' 8 + import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' 8 9 import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' 9 10 10 11 const is$typed = _is$typed, ··· 14 15 export interface Record { 15 16 $type: 'pub.leaflet.document' 16 17 title: string 18 + postRef?: ComAtprotoRepoStrongRef.Main 17 19 description?: string 18 20 publishedAt?: string 19 21 publication: string
+27
lexicons/api/types/pub/leaflet/publication/subscription.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' 8 + 9 + const is$typed = _is$typed, 10 + validate = _validate 11 + const id = 'pub.leaflet.publication.subscription' 12 + 13 + export interface Record { 14 + $type: 'pub.leaflet.publication.subscription' 15 + publication: string 16 + [k: string]: unknown 17 + } 18 + 19 + const hashRecord = 'main' 20 + 21 + export function isRecord<V>(v: V) { 22 + return is$typed(v, id, hashRecord) 23 + } 24 + 25 + export function validateRecord<V>(v: V) { 26 + return validate<Record & V>(v, id, hashRecord, true) 27 + }
+4
lexicons/pub/leaflet/document.json
··· 22 22 "maxLength": 1280, 23 23 "maxGraphemes": 128 24 24 }, 25 + "postRef": { 26 + "type": "ref", 27 + "ref": "com.atproto.repo.strongRef" 28 + }, 25 29 "description": { 26 30 "type": "string", 27 31 "maxLength": 3000,
+23
lexicons/pub/leaflet/publication/subscription.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.publication.subscription", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "description": "Record declaring a subscription to a publication", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "publication" 13 + ], 14 + "properties": { 15 + "publication": { 16 + "type": "string", 17 + "format": "at-uri" 18 + } 19 + } 20 + } 21 + } 22 + } 23 + }
+1
lexicons/src/document.ts
··· 16 16 required: ["pages", "author", "title", "publication"], 17 17 properties: { 18 18 title: { type: "string", maxLength: 1280, maxGraphemes: 128 }, 19 + postRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, 19 20 description: { type: "string", maxLength: 3000, maxGraphemes: 300 }, 20 21 publishedAt: { type: "string", format: "datetime" }, 21 22 publication: { type: "string", format: "at-uri" },
+19
lexicons/src/publication.ts
··· 21 21 }, 22 22 }, 23 23 }; 24 + 25 + export const PubLeafletPublicationSubscription: LexiconDoc = { 26 + lexicon: 1, 27 + id: "pub.leaflet.publication.subscription", 28 + defs: { 29 + main: { 30 + type: "record", 31 + key: "tid", 32 + description: "Record declaring a subscription to a publication", 33 + record: { 34 + type: "object", 35 + required: ["publication"], 36 + properties: { 37 + publication: { type: "string", format: "at-uri" }, 38 + }, 39 + }, 40 + }, 41 + }, 42 + };
+32
supabase/database.types.ts
··· 684 684 }, 685 685 ] 686 686 } 687 + publication_subscriptions: { 688 + Row: { 689 + created_at: string 690 + identity: string 691 + publication: string 692 + record: Json 693 + uri: string | null 694 + } 695 + Insert: { 696 + created_at?: string 697 + identity: string 698 + publication: string 699 + record: Json 700 + uri?: string | null 701 + } 702 + Update: { 703 + created_at?: string 704 + identity?: string 705 + publication?: string 706 + record?: Json 707 + uri?: string | null 708 + } 709 + Relationships: [ 710 + { 711 + foreignKeyName: "publication_subscriptions_publication_fkey" 712 + columns: ["publication"] 713 + isOneToOne: false 714 + referencedRelation: "publications" 715 + referencedColumns: ["uri"] 716 + }, 717 + ] 718 + } 687 719 publications: { 688 720 Row: { 689 721 identity_did: string
+64
supabase/migrations/20250605003641_add_publication_subscriptions_table.sql
··· 1 + create table "public"."publication_subscriptions" ( 2 + "publication" text not null, 3 + "identity" text not null, 4 + "created_at" timestamp with time zone not null default now(), 5 + "record" jsonb not null, 6 + "uri" text 7 + ); 8 + 9 + 10 + alter table "public"."publication_subscriptions" enable row level security; 11 + 12 + CREATE UNIQUE INDEX publication_subscriptions_pkey ON public.publication_subscriptions USING btree (publication, identity); 13 + 14 + CREATE UNIQUE INDEX publication_subscriptions_uri_key ON public.publication_subscriptions USING btree (uri); 15 + 16 + alter table "public"."publication_subscriptions" add constraint "publication_subscriptions_pkey" PRIMARY KEY using index "publication_subscriptions_pkey"; 17 + 18 + alter table "public"."publication_subscriptions" add constraint "publication_subscriptions_publication_fkey" FOREIGN KEY (publication) REFERENCES publications(uri) ON DELETE CASCADE not valid; 19 + 20 + alter table "public"."publication_subscriptions" validate constraint "publication_subscriptions_publication_fkey"; 21 + 22 + alter table "public"."publication_subscriptions" add constraint "publication_subscriptions_uri_key" UNIQUE using index "publication_subscriptions_uri_key"; 23 + 24 + grant delete on table "public"."publication_subscriptions" to "anon"; 25 + 26 + grant insert on table "public"."publication_subscriptions" to "anon"; 27 + 28 + grant references on table "public"."publication_subscriptions" to "anon"; 29 + 30 + grant select on table "public"."publication_subscriptions" to "anon"; 31 + 32 + grant trigger on table "public"."publication_subscriptions" to "anon"; 33 + 34 + grant truncate on table "public"."publication_subscriptions" to "anon"; 35 + 36 + grant update on table "public"."publication_subscriptions" to "anon"; 37 + 38 + grant delete on table "public"."publication_subscriptions" to "authenticated"; 39 + 40 + grant insert on table "public"."publication_subscriptions" to "authenticated"; 41 + 42 + grant references on table "public"."publication_subscriptions" to "authenticated"; 43 + 44 + grant select on table "public"."publication_subscriptions" to "authenticated"; 45 + 46 + grant trigger on table "public"."publication_subscriptions" to "authenticated"; 47 + 48 + grant truncate on table "public"."publication_subscriptions" to "authenticated"; 49 + 50 + grant update on table "public"."publication_subscriptions" to "authenticated"; 51 + 52 + grant delete on table "public"."publication_subscriptions" to "service_role"; 53 + 54 + grant insert on table "public"."publication_subscriptions" to "service_role"; 55 + 56 + grant references on table "public"."publication_subscriptions" to "service_role"; 57 + 58 + grant select on table "public"."publication_subscriptions" to "service_role"; 59 + 60 + grant trigger on table "public"."publication_subscriptions" to "service_role"; 61 + 62 + grant truncate on table "public"."publication_subscriptions" to "service_role"; 63 + 64 + grant update on table "public"."publication_subscriptions" to "service_role";