Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 300 lines 11 kB view raw
1import { isValid, parseJSON } from 'date-fns'; 2import { type Opaque } from 'type-fest'; 3 4// Simple way to make sure that a type T always extends a type U 5// as the types change over time (and to get a type error if not). 6// This is kinda analogous to the `satisfies` operator, but at the type level. 7// See https://github.com/microsoft/TypeScript/issues/52222 8type Satisfies<T extends U, U> = T; 9 10// TODO: Representing geographical _points_ and _areas_ with the same scalar 11// type is probably not ideal. 12// 13// NB: the ID type refers to ids for any type of entity, but USER_ID should be 14// used instead for fields that hold ids of users coop might know about. E.g., 15// imagine a `Message` content type. Both the `to` and `from` fields could hold 16// ScalarTypes.USER_IDs, and then a rule could flag the message if _either_ the 17// sender or the recipient satisfies some condition (after passing the user id 18// to a user-related signal). 19export const ScalarTypes = makeEnumLike([ 20 'USER_ID', 21 'ID', 22 'STRING', 23 'BOOLEAN', 24 'NUMBER', 25 'AUDIO', 26 'IMAGE', 27 'VIDEO', 28 'DATETIME', 29 'GEOHASH', 30 'RELATED_ITEM', 31 'URL', 32 'POLICY_ID', 33]); 34export type ScalarTypes = typeof ScalarTypes; 35export type ScalarType = keyof typeof ScalarTypes; 36 37export const ContainerTypes = makeEnumLike(['ARRAY', 'MAP']); 38export type ContainerTypes = typeof ContainerTypes; 39export type ContainerType = keyof ContainerTypes; 40 41const containerTypes = new Set(Object.values(ContainerTypes)); 42 43type ScalarTypeRuntimeTypeMapping = Satisfies< 44 { 45 [ScalarTypes.STRING]: string; 46 [ScalarTypes.ID]: string; 47 [ScalarTypes.USER_ID]: ItemIdentifier; 48 [ScalarTypes.GEOHASH]: string; 49 [ScalarTypes.URL]: UrlString; 50 [ScalarTypes.BOOLEAN]: boolean; 51 [ScalarTypes.NUMBER]: number; 52 [ScalarTypes.DATETIME]: DateString; 53 [ScalarTypes.AUDIO]: { url: string }; 54 [ScalarTypes.IMAGE]: { url: string }; 55 [ScalarTypes.VIDEO]: { url: string }; 56 [ScalarTypes.RELATED_ITEM]: RelatedItem; 57 [ScalarTypes.POLICY_ID]: string; 58 }, 59 { [K in ScalarType]: unknown } 60>; 61 62type ContainerTypeRuntimeTypeMapping< 63 ValueType extends ScalarType = ScalarType, 64> = Satisfies< 65 { 66 [ContainerTypes.ARRAY]: ScalarTypeRuntimeType<ValueType>[]; 67 // can just use a js object (not a Map) b/c the content came from JSON, 68 // so the keys will have been strings 69 [ContainerTypes.MAP]: { [key: string]: ScalarTypeRuntimeType<ValueType> }; 70 }, 71 { [K in ContainerType]: unknown } 72>; 73 74export type ScalarTypeRuntimeType<T extends ScalarType = ScalarType> = 75 ScalarTypeRuntimeTypeMapping[T]; 76 77export type FieldType = ScalarType | ContainerType; 78 79export type Container<T extends ContainerType> = Readonly<{ 80 // TODO: delete this key? (That would prevent mismatches between field.type 81 // and field.container.containerType, but would require a migration and make 82 // these item descriptions harder to process independently of the owning Field.) 83 containerType: T; 84 85 // NB: because the input content is JSON, the key types can really only be 86 // types that are strings or losslessly/unambiguously convertible to strings. 87 // Currently, though, the frontend (and so also our types here) don't perform 88 // any validation and allow all types. 89 keyScalarType: Satisfies< 90 { [ContainerTypes.MAP]: ScalarType; [ContainerTypes.ARRAY]: null }, 91 { [K in ContainerType]: unknown } 92 >[T]; 93 valueScalarType: ScalarType; 94}>; 95 96export type Field<T extends FieldType = FieldType> = 97 | { 98 [Type in ScalarType]: { 99 name: string; 100 type: Type; 101 required: boolean; 102 container: null; 103 }; 104 }[ScalarType & T] 105 | { 106 [Type in ContainerType]: { 107 name: string; 108 type: Type; 109 required: boolean; 110 // NB: `container` is a misnomer, since really the _Field_ is the container. 111 // This should be called "items" or similar, as it describes the shape of the items. 112 container: Container<Type>; 113 }; 114 }[ContainerType & T]; 115 116export type ContainerTypeRuntimeType< 117 T extends ContainerType, 118 V extends ScalarType = ScalarType, 119> = ContainerTypeRuntimeTypeMapping<V>[T]; 120 121export type FieldTypeRuntimeType< 122 T extends FieldType, 123 ContainerValueType extends ScalarType = ScalarType, 124> = 125 | ScalarTypeRuntimeType<T & ScalarType> 126 | ContainerTypeRuntimeType<T & ContainerType, ContainerValueType>; 127 128/** 129 * In the case of scalar fields, this returns the ScalarType of their single 130 * value; in the case of container fields, it gives the type of the scalars in 131 * the container (i.e., the ScalarType for the array's items or map's values). 132 * With container fields, we don't track details at the type level of what 133 * scalars they contain, so this type assumes it could be anything. 134 */ 135export type FieldScalarType<T extends FieldType> = T extends ScalarType 136 ? T 137 : ScalarType; 138 139/** 140 * A TaggedScalar holds a scalar value, along with a label identifying its 141 * ScalarTypes. This label is necessary because not all scalar values have a 142 * unique js runtime representation (e.g., ScalarTypes.STRING and 143 * ScalarTypes.GEOHASH are both represented as strings), so, without the label, 144 * we wouldn't know unambiguously which ScalarType a value belongs to, which we 145 * need sometimes (e.g., when deciding whether we can pass it to a signal). 146 */ 147type EnumScalarType = ScalarTypes['STRING'] | ScalarTypes['NUMBER']; 148 149export type TaggedScalar<T extends ScalarType> = { 150 [K in ScalarType]: 151 | { type: K; value: ScalarTypeRuntimeType<K> } 152 | (K extends EnumScalarType 153 ? { 154 type: K; 155 value: ScalarTypeRuntimeType<K>; 156 enum: readonly ScalarTypeRuntimeType<K>[]; 157 ordered: boolean; 158 } 159 : never); 160}[T]; 161 162export function isContainerField(it: Field): it is Field<ContainerType> { 163 return isContainerType(it.type); 164} 165 166export function isContainerType(it: FieldType): it is ContainerType { 167 return containerTypes.has(it as ContainerType); 168} 169 170export function getScalarType<T extends FieldType>(it: Field<T>) { 171 return ( 172 isContainerField(it) ? it.container.valueScalarType : it.type 173 ) as FieldScalarType<T>; 174} 175 176export function isMediaType(it: ScalarType): boolean { 177 return ( 178 it === ScalarTypes.AUDIO || 179 it === ScalarTypes.VIDEO || 180 it === ScalarTypes.IMAGE 181 ); 182} 183 184export function isMediaValue<T extends ScalarType>( 185 it: TaggedScalar<T>, 186): it is TaggedScalar< 187 T & (ScalarTypes['IMAGE'] | ScalarTypes['VIDEO'] | ScalarTypes['AUDIO']) 188> { 189 return isMediaType(it.type); 190} 191 192/** 193 * Takes an array of strings and returns an object with a property for each 194 * string in the array, where the string is used as both the name and value for 195 * the property. 196 * 197 * This is useful to get type safety and automatic refactoring in some cases. 198 * E.g., imagine you're setting the default value for a field on a Sequelize 199 * model. Let's say the field can have three legal values: 'a', 'b', or 'c'. 200 * So, you'll initialize the model with some config object for the field, like 201 * `{ defaultValue: 'a' }`. Now, the type that this `defaultValue` key expects 202 * will be very vague -- likely `string` or maybe even `any` -- because it was 203 * defined by Sequelize and doesn't know about your field's specific legal 204 * values. Therefore, you could write `{ defaultValue: 'invalid' }` and TS 205 * wouldn't complain; moreover, even if you wrote `{ defaultValue: 'a' }`, which 206 * would be correct at the time, a rename on the value 'a' would not 207 * automatically rename the value here, because they're not linked by type. 208 * 209 * To fix these issues, it can be very helpful to have an object like 210 * `const legalValues = { 'a': 'a', 'b': 'b', 'c': 'c' };` because, then, 211 * you can do `{ defaultValues: legalValues.a }`, and you're guaranteed a typo- 212 * free and rename-friendly value. That `legalValues` object is what this 213 * function makes. 214 * 215 * Obviously, such an object is similar to a TS enum (hence this function's 216 * name). The key difference, though, is that the values in this object are 217 * typed as string literals, whereas the value for each case in an enum is 218 * treated by the type system as intentionally opaque. So, having that 219 * visibility in an 'enum-like' can help a lot with assignability, when the 220 * source value is a string literal type (rather than the source having to have 221 * been constructed with the same enum). 222 * 223 * We also exploit this for assigning values that come in from GraphQL. The 224 * GraphQL value is an enum; we want to use a different type in our inner layers 225 * (which shouldn't be coupled to GraphQL); but, if our internal type were an 226 * enum, the GraphQL enum wouldn't be assignable to it (even if their runtime 227 * values match). However, if the internal type is an "enum like", then TS will 228 * allow GraphQL enum to be assignable to it iff the enum's runtime values are 229 * legal values in the enum like. 230 */ 231export function makeEnumLike<T extends string>(strings: readonly T[]) { 232 return Object.fromEntries(strings.map((it) => [it, it])) as { [K in T]: K }; 233} 234 235// This is a helper type for classifiers with subcategories. Different services 236// have different structures. We will always compare a rule's subcategory 237// value using the 'id' field. 238export type SignalSubcategory = { 239 id: string; 240 label: string; 241 description?: string; 242 children: SignalSubcategory[]; 243}; 244 245export type DateString = Opaque<string, 'DateString'>; 246export function parseDateString(it: DateString): Date { 247 return new Date(it); 248} 249/** 250 * Returns a DateString if the input string can be parsed 251 * as a date; else undefined. Accepts strings in a rather limited set 252 * of formats for now. (See {@link parseJSON} docs for details.) 253 */ 254export function makeDateString(it: string) { 255 const potentialDate = parseJSON(it); 256 257 // check if the parsing succeeded; return accordingly 258 return isValid(potentialDate) 259 ? (potentialDate.toISOString() as DateString) 260 : undefined; 261} 262 263export type RelatedItem = Satisfies< 264 { id: string; typeId: string; name?: string }, 265 ItemIdentifier 266>; 267 268/** 269 * UrlString represents a string that's known to be parsable into a valid URL; 270 * analogous to DateString. 271 */ 272export type UrlString = Opaque<string, 'UrlString'>; 273 274// Items 275export type ItemIdentifier = Readonly<{ id: string; typeId: string }>; 276 277export const ItemTypeKind = makeEnumLike(['CONTENT', 'THREAD', 'USER']); 278export type ItemTypeKind = keyof typeof ItemTypeKind; 279 280// Integration plugin types (for third-party integrations and adopters' config) 281export type { 282 CoopIntegrationConfigEntry, 283 CoopIntegrationPlugin, 284 CoopIntegrationsConfig, 285 IntegrationConfigField, 286 IntegrationId, 287 IntegrationManifest, 288 ModelCard, 289 ModelCardField, 290 ModelCardSection, 291 ModelCardSubsection, 292 PluginSignalContext, 293 PluginSignalDescriptor, 294 StoredIntegrationConfigPayload, 295} from './integration.js'; 296export { 297 assertModelCardHasRequiredSections, 298 isCoopIntegrationPlugin, 299 REQUIRED_MODEL_CARD_SECTION_IDS, 300} from './integration.js';