Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
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';