Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
10
fork

Configure Feed

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

feat: fetch lexicon schema for data sources

Hugo 89f6a29e 80c1636d

+384 -147
+4 -2
CLAUDE.md
··· 14 14 15 15 ## Commands 16 16 17 - - `vp dev`: start dev server 18 - - `vp build --mode client`: build client assets 17 + You have access to `vp`. NO need to run `bunx vp`. 18 + 19 19 - `vp check`: lint, format, type-check 20 20 - instead of `tsc --noEmit` 21 + - `vp dev`: start dev server 22 + - `vp build --mode client`: build client assets 21 23 - `vp test`: run tests 22 24 - `bun run start`: run production server 23 25 - `bun run db:generate`: generate Drizzle migrations
+364 -145
app/islands/AutomationForm.tsx
··· 25 25 description?: string; 26 26 }; 27 27 28 + type FetchSchemaState = { 29 + loading: boolean; 30 + unresolved: boolean; 31 + error: string; 32 + fields: Field[]; 33 + }; 34 + 28 35 type Condition = { 29 36 field: string; 30 37 operator: string; ··· 120 127 121 128 const BUILTIN_CONDITION_FIELDS: Field[] = [ 122 129 { path: "event.did", type: "string", description: "DID of the repo that emitted the event" }, 130 + ]; 131 + 132 + const FETCH_ENTRY_FIELDS: Field[] = [ 133 + { path: "found", type: "boolean", description: "Whether a record was returned" }, 134 + { path: "uri", type: "string", description: "AT URI of the resolved record" }, 135 + { path: "cid", type: "string", description: "Content hash" }, 136 + { path: "did", type: "string", description: "DID of the record's owner" }, 137 + { path: "collection", type: "string", description: "NSID of the record's collection" }, 138 + { path: "rkey", type: "string", description: "Record key" }, 123 139 ]; 124 140 125 141 const BUILTIN_PLACEHOLDERS = [ ··· 710 726 ); 711 727 } 712 728 729 + function FetchSchemaStatus({ 730 + state, 731 + }: { 732 + state?: { loading: boolean; unresolved: boolean; error: string; fields: { path: string }[] }; 733 + }) { 734 + if (!state) return null; 735 + if (state.loading) return <span class={s.hint}>Loading fields...</span>; 736 + if (state.error) return <span class={s.errorText}>{state.error}</span>; 737 + if (state.unresolved) 738 + return <span class={s.hint}>Schema unresolved — placeholders fall back to wildcard.</span>; 739 + if (state.fields.length > 0) 740 + return <span class={s.hint}>{state.fields.length} typed fields available.</span>; 741 + return null; 742 + } 743 + 713 744 // --------------------------------------------------------------------------- 714 745 // Main form 715 746 // --------------------------------------------------------------------------- ··· 794 825 name: f.name, 795 826 uri: f.uri, 796 827 repo: "", 797 - collection: "", 828 + collection: f.collection ?? "", 798 829 whereField: "", 799 830 whereValue: "", 800 831 conditions, ··· 852 883 ...comment, 853 884 }; 854 885 } 855 - return { kind: "record" as const, name: f.name, uri: f.uri, ...conditions, ...comment }; 886 + const collection = f.collection ? { collection: f.collection } : {}; 887 + return { 888 + kind: "record" as const, 889 + name: f.name, 890 + uri: f.uri, 891 + ...collection, 892 + ...conditions, 893 + ...comment, 894 + }; 856 895 } 857 896 858 897 function toConditionDrafts( ··· 889 928 const [fieldsLoading, setFieldsLoading] = useState(false); 890 929 const [fieldsError, setFieldsError] = useState(""); 891 930 const [schemaUnresolved, setSchemaUnresolved] = useState(false); 931 + const [fetchSchemas, setFetchSchemas] = useState<Record<string, FetchSchemaState>>({}); 932 + const fetchSchemaDebounceRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map()); 892 933 const [conditions, setConditions] = useState<Condition[]>( 893 934 initial ? toConditionDrafts(initial.conditions) : [], 894 935 ); ··· 1014 1055 }, 300); 1015 1056 }, []); 1016 1057 1058 + const loadFetchSchema = useCallback((nsid: string) => { 1059 + if (!nsid || !NSID_RE.test(nsid)) return; 1060 + setFetchSchemas((prev) => { 1061 + if (prev[nsid]) return prev; 1062 + return { ...prev, [nsid]: { loading: true, unresolved: false, error: "", fields: [] } }; 1063 + }); 1064 + const debounces = fetchSchemaDebounceRef.current!; 1065 + const existing = debounces.get(nsid); 1066 + if (existing) clearTimeout(existing); 1067 + const handle = setTimeout(async () => { 1068 + try { 1069 + const res = await fetch(`/api/lexicons/${encodeURIComponent(nsid)}`); 1070 + const data = await res.json(); 1071 + if (!res.ok) { 1072 + setFetchSchemas((prev) => ({ 1073 + ...prev, 1074 + [nsid]: { 1075 + loading: false, 1076 + unresolved: res.status === 404, 1077 + error: res.status === 404 ? "" : data.error || "Failed to load fields", 1078 + fields: [], 1079 + }, 1080 + })); 1081 + } else { 1082 + setFetchSchemas((prev) => ({ 1083 + ...prev, 1084 + [nsid]: { 1085 + loading: false, 1086 + unresolved: false, 1087 + error: "", 1088 + fields: data.fields || [], 1089 + }, 1090 + })); 1091 + } 1092 + } catch { 1093 + setFetchSchemas((prev) => ({ 1094 + ...prev, 1095 + [nsid]: { 1096 + loading: false, 1097 + unresolved: false, 1098 + error: "Failed to fetch lexicon fields", 1099 + fields: [], 1100 + }, 1101 + })); 1102 + } 1103 + }, 400); 1104 + debounces.set(nsid, handle); 1105 + }, []); 1106 + 1017 1107 if (!initialFetched.current && initialLexicon) { 1018 1108 initialFetched.current = true; 1019 1109 fetchFields(initialLexicon, false); 1110 + } 1111 + 1112 + const initialFetchSchemasPrimed = useRef(false); 1113 + if (!initialFetchSchemasPrimed.current && initial) { 1114 + initialFetchSchemasPrimed.current = true; 1115 + for (const f of initial.fetches) { 1116 + if (f.collection) loadFetchSchema(f.collection); 1117 + } 1020 1118 } 1021 1119 1022 1120 const addCondition = useCallback(() => { ··· 1092 1190 return { ...f, [key]: val }; 1093 1191 }), 1094 1192 ); 1193 + if (key === "collection" && val && NSID_RE.test(val)) loadFetchSchema(val); 1095 1194 }, 1096 - [], 1195 + [loadFetchSchema], 1097 1196 ); 1098 1197 1099 1198 const addFetchCondition = useCallback((fetchIndex: number) => { ··· 1356 1455 ...fields.map((f) => `event.commit.record.${f.path}`), 1357 1456 ...fetches 1358 1457 .filter((f) => f.name) 1359 - .flatMap((f) => [ 1360 - `${f.name}.uri`, 1361 - `${f.name}.cid`, 1362 - `${f.name}.did`, 1363 - `${f.name}.collection`, 1364 - `${f.name}.rkey`, 1365 - `${f.name}.record.*`, 1366 - ]), 1458 + .flatMap((f) => { 1459 + const base = [ 1460 + `${f.name}.uri`, 1461 + `${f.name}.cid`, 1462 + `${f.name}.did`, 1463 + `${f.name}.collection`, 1464 + `${f.name}.rkey`, 1465 + ]; 1466 + const schema = f.collection ? fetchSchemas[f.collection] : undefined; 1467 + const recordPaths = 1468 + schema && schema.fields.length > 0 1469 + ? schema.fields.map((field) => `${f.name}.record.${field.path}`) 1470 + : [`${f.name}.record.*`]; 1471 + return [...base, ...recordPaths]; 1472 + }), 1367 1473 ...actions.flatMap((a, i) => 1368 1474 isRecordProducingAction(a.type) 1369 1475 ? [ ··· 1804 1910 {f.kind === "record" ? ( 1805 1911 <div class={s.fetchSubsection}> 1806 1912 <div class={s.fetchSubsectionTitle}>Record to fetch</div> 1807 - <div class={s.fieldGroup}> 1808 - <label class={s.label}>AT URI</label> 1809 - <input 1810 - class={s.input} 1811 - type="text" 1812 - placeholder="at://{{event.commit.record.subject}}" 1813 - value={f.uri} 1814 - onInput={(e: Event) => 1815 - updateFetch(i, "uri", (e.target as HTMLInputElement).value) 1816 - } 1817 - /> 1818 - <span class={s.hint}> 1819 - Supports <code class={s.inlineCode}>{"{{event.*}}"}</code>,{" "} 1820 - <code class={s.inlineCode}>{"{{self}}"}</code>. 1821 - </span> 1913 + <div class={s.twoColRow}> 1914 + <div class={s.fieldGroup}> 1915 + <label class={s.label}>AT URI</label> 1916 + <input 1917 + class={s.input} 1918 + type="text" 1919 + placeholder="{{event.commit.record.subject}}" 1920 + value={f.uri} 1921 + onInput={(e: Event) => 1922 + updateFetch(i, "uri", (e.target as HTMLInputElement).value) 1923 + } 1924 + /> 1925 + <span class={s.hint}> 1926 + Supports <code class={s.inlineCode}>{"{{event.*}}"}</code>,{" "} 1927 + <code class={s.inlineCode}>{"{{self}}"}</code>. 1928 + </span> 1929 + </div> 1930 + <div class={s.fieldGroup}> 1931 + <label class={s.label}> 1932 + Collection <span class={s.hint}>(optional)</span> 1933 + </label> 1934 + <input 1935 + class={s.input} 1936 + type="text" 1937 + placeholder="app.bsky.feed.post" 1938 + value={f.collection} 1939 + onInput={(e: Event) => 1940 + updateFetch(i, "collection", (e.target as HTMLInputElement).value) 1941 + } 1942 + /> 1943 + <FetchSchemaStatus state={fetchSchemas[f.collection]} /> 1944 + <span class={s.hint}>NSID hint for typed placeholders.</span> 1945 + </div> 1822 1946 </div> 1823 1947 </div> 1824 1948 ) : ( ··· 1853 1977 updateFetch(i, "collection", (e.target as HTMLInputElement).value) 1854 1978 } 1855 1979 /> 1980 + <FetchSchemaStatus state={fetchSchemas[f.collection]} /> 1856 1981 <span class={s.hint}>NSID of the collection.</span> 1857 1982 </div> 1858 1983 </div> ··· 1905 2030 <span class={s.hint}> 1906 2031 Runs after this fetch. If any check fails, the automation skips. 1907 2032 </span> 1908 - {f.conditions.map((cond, ci) => { 1909 - const preset = fetchConditionPreset(cond); 1910 - return ( 1911 - <div key={ci} class={s.fetchConditionCard}> 1912 - <div class={s.fetchConditionPresetRow}> 1913 - <select 1914 - class={s.select} 1915 - value={preset} 1916 - onChange={(e: Event) => 1917 - setFetchConditionPreset( 1918 - i, 1919 - ci, 1920 - (e.target as HTMLSelectElement).value as FetchConditionPreset, 1921 - ) 1922 - } 1923 - style={{ flex: "1 1 auto", minWidth: 0 }} 1924 - > 1925 - <option value="found"> 1926 - {f.kind === "search" 1927 - ? "A matching record is found" 1928 - : "The record is found"} 1929 - </option> 1930 - <option value="not-found"> 1931 - {f.kind === "search" 1932 - ? "No matching record is found" 1933 - : "The record is not found"} 1934 - </option> 1935 - <option value="custom">Custom field check…</option> 1936 - </select> 1937 - <button 1938 - type="button" 1939 - class={s.removeBtn} 1940 - onClick={() => removeFetchCondition(i, ci)} 1941 - > 1942 - Remove 1943 - </button> 1944 - </div> 1945 - {preset === "custom" && ( 1946 - <div class={s.conditionRow}> 1947 - <div class={s.conditionField}> 1948 - <input 1949 - class={s.input} 1950 - type="text" 1951 - placeholder="record.subject" 1952 - value={cond.field} 1953 - onInput={(e: Event) => 1954 - updateFetchCondition( 1955 - i, 1956 - ci, 1957 - "field", 1958 - (e.target as HTMLInputElement).value, 1959 - ) 1960 - } 1961 - /> 1962 - </div> 1963 - <div class={s.conditionOperator}> 1964 - <select 1965 - class={s.select} 1966 - value={cond.operator} 1967 - onChange={(e: Event) => 1968 - updateFetchCondition( 1969 - i, 1970 - ci, 1971 - "operator", 1972 - (e.target as HTMLSelectElement).value, 1973 - ) 1974 - } 1975 - > 1976 - <option value="eq">equals</option> 1977 - <option value="startsWith">starts with</option> 1978 - <option value="endsWith">ends with</option> 1979 - <option value="contains">contains</option> 1980 - <option value="exists">is present</option> 1981 - <option value="not-exists">is missing</option> 1982 - </select> 1983 - </div> 1984 - {!VALUE_LESS_CONDITION_OPS.has(cond.operator) && ( 1985 - <div class={s.conditionValue}> 1986 - <input 1987 - class={s.input} 1988 - type="text" 1989 - placeholder="Value to compare" 1990 - value={cond.value} 1991 - onInput={(e: Event) => 1992 - updateFetchCondition( 1993 - i, 1994 - ci, 1995 - "value", 1996 - (e.target as HTMLInputElement).value, 1997 - ) 1998 - } 1999 - /> 2033 + {(() => { 2034 + const schemaState = f.collection ? fetchSchemas[f.collection] : undefined; 2035 + const recordFields = schemaState?.fields ?? []; 2036 + const condOptions: Field[] = [ 2037 + ...FETCH_ENTRY_FIELDS, 2038 + ...recordFields.map((field) => ({ 2039 + ...field, 2040 + path: `record.${field.path}`, 2041 + })), 2042 + ]; 2043 + const useFreeFormField = 2044 + !f.collection || 2045 + (!!schemaState && 2046 + (schemaState.unresolved || !!schemaState.error) && 2047 + recordFields.length === 0); 2048 + return f.conditions.map((cond, ci) => { 2049 + const preset = fetchConditionPreset(cond); 2050 + const selectedField = condOptions.find((cf) => cf.path === cond.field); 2051 + const isBoolean = selectedField?.type === "boolean"; 2052 + return ( 2053 + <div key={ci} class={s.fetchConditionCard}> 2054 + <div class={s.fetchConditionPresetRow}> 2055 + <select 2056 + class={s.select} 2057 + value={preset} 2058 + onChange={(e: Event) => 2059 + setFetchConditionPreset( 2060 + i, 2061 + ci, 2062 + (e.target as HTMLSelectElement).value as FetchConditionPreset, 2063 + ) 2064 + } 2065 + style={{ flex: "1 1 auto", minWidth: 0 }} 2066 + > 2067 + <option value="found"> 2068 + {f.kind === "search" 2069 + ? "A matching record is found" 2070 + : "The record is found"} 2071 + </option> 2072 + <option value="not-found"> 2073 + {f.kind === "search" 2074 + ? "No matching record is found" 2075 + : "The record is not found"} 2076 + </option> 2077 + <option value="custom">Custom field check…</option> 2078 + </select> 2079 + <button 2080 + type="button" 2081 + class={s.removeBtn} 2082 + onClick={() => removeFetchCondition(i, ci)} 2083 + > 2084 + Remove 2085 + </button> 2086 + </div> 2087 + {preset === "custom" && ( 2088 + <div class={s.conditionRow}> 2089 + <div class={s.conditionField}> 2090 + {useFreeFormField ? ( 2091 + <input 2092 + class={s.input} 2093 + type="text" 2094 + placeholder="record.subject" 2095 + value={cond.field} 2096 + onInput={(e: Event) => 2097 + updateFetchCondition( 2098 + i, 2099 + ci, 2100 + "field", 2101 + (e.target as HTMLInputElement).value, 2102 + ) 2103 + } 2104 + /> 2105 + ) : ( 2106 + <> 2107 + <select 2108 + class={s.select} 2109 + value={cond.field} 2110 + disabled={schemaState?.loading} 2111 + onChange={(e: Event) => 2112 + updateFetchCondition( 2113 + i, 2114 + ci, 2115 + "field", 2116 + (e.target as HTMLSelectElement).value, 2117 + ) 2118 + } 2119 + > 2120 + <option value=""> 2121 + {schemaState?.loading 2122 + ? "Loading fields..." 2123 + : "Select field..."} 2124 + </option> 2125 + {condOptions.map((cf) => ( 2126 + <option key={cf.path} value={cf.path}> 2127 + {cf.path} 2128 + </option> 2129 + ))} 2130 + </select> 2131 + {selectedField?.description && ( 2132 + <span class={s.hint}>{selectedField.description}</span> 2133 + )} 2134 + </> 2135 + )} 2000 2136 </div> 2001 - )} 2002 - </div> 2003 - )} 2004 - </div> 2005 - ); 2006 - })} 2137 + {cond.field && !isBoolean && ( 2138 + <div class={s.conditionOperator}> 2139 + <select 2140 + class={s.select} 2141 + value={cond.operator} 2142 + onChange={(e: Event) => 2143 + updateFetchCondition( 2144 + i, 2145 + ci, 2146 + "operator", 2147 + (e.target as HTMLSelectElement).value, 2148 + ) 2149 + } 2150 + > 2151 + <option value="eq">equals</option> 2152 + <option value="startsWith">starts with</option> 2153 + <option value="endsWith">ends with</option> 2154 + <option value="contains">contains</option> 2155 + <option value="exists">is present</option> 2156 + <option value="not-exists">is missing</option> 2157 + </select> 2158 + </div> 2159 + )} 2160 + {!VALUE_LESS_CONDITION_OPS.has(cond.operator) && ( 2161 + <div class={s.conditionValue}> 2162 + {isBoolean ? ( 2163 + <select 2164 + class={s.select} 2165 + value={cond.value} 2166 + onChange={(e: Event) => 2167 + updateFetchCondition( 2168 + i, 2169 + ci, 2170 + "value", 2171 + (e.target as HTMLSelectElement).value, 2172 + ) 2173 + } 2174 + > 2175 + <option value="">Select...</option> 2176 + <option value="true">true</option> 2177 + <option value="false">false</option> 2178 + </select> 2179 + ) : ( 2180 + <input 2181 + class={s.input} 2182 + type="text" 2183 + placeholder="Value to compare" 2184 + value={cond.value} 2185 + onInput={(e: Event) => 2186 + updateFetchCondition( 2187 + i, 2188 + ci, 2189 + "value", 2190 + (e.target as HTMLInputElement).value, 2191 + ) 2192 + } 2193 + /> 2194 + )} 2195 + </div> 2196 + )} 2197 + </div> 2198 + )} 2199 + </div> 2200 + ); 2201 + }); 2202 + })()} 2007 2203 <button type="button" class={s.addBtn} onClick={() => addFetchCondition(i)}> 2008 2204 + Add check 2009 2205 </button> ··· 2025 2221 /> 2026 2222 </div> 2027 2223 2028 - {f.name && ( 2029 - <details class={s.collapsibleDetails}> 2030 - <summary class={s.collapsibleSummary}> 2031 - Placeholders from <code class={s.inlineCode}>{f.name}</code> 2032 - </summary> 2033 - <div class={s.collapsibleContent}> 2034 - <div class={s.placeholderGroup}> 2035 - <CopyPlaceholder value={`${f.name}.uri`} /> 2036 - <CopyPlaceholder value={`${f.name}.cid`} /> 2037 - <CopyPlaceholder value={`${f.name}.did`}> 2038 - <span class={s.placeholderDesc}>owner DID</span> 2039 - </CopyPlaceholder> 2040 - <CopyPlaceholder value={`${f.name}.collection`} /> 2041 - <CopyPlaceholder value={`${f.name}.rkey`} /> 2042 - <CopyPlaceholder value={`${f.name}.record.*`}> 2043 - <span class={s.placeholderDesc}>access nested fields</span> 2044 - </CopyPlaceholder> 2045 - </div> 2046 - </div> 2047 - </details> 2048 - )} 2224 + {f.name && 2225 + (() => { 2226 + const schema = f.collection ? fetchSchemas[f.collection] : undefined; 2227 + const typedFields = schema?.fields ?? []; 2228 + return ( 2229 + <details class={s.collapsibleDetails}> 2230 + <summary class={s.collapsibleSummary}> 2231 + Placeholders from <code class={s.inlineCode}>{f.name}</code> 2232 + </summary> 2233 + <div class={s.collapsibleContent}> 2234 + <div class={s.placeholderGroup}> 2235 + <CopyPlaceholder value={`${f.name}.uri`} /> 2236 + <CopyPlaceholder value={`${f.name}.cid`} /> 2237 + <CopyPlaceholder value={`${f.name}.did`}> 2238 + <span class={s.placeholderDesc}>owner DID</span> 2239 + </CopyPlaceholder> 2240 + <CopyPlaceholder value={`${f.name}.collection`} /> 2241 + <CopyPlaceholder value={`${f.name}.rkey`} /> 2242 + </div> 2243 + {typedFields.length > 0 ? ( 2244 + <div class={s.placeholderGroup}> 2245 + <div class={s.placeholderGroupTitle}>Record fields</div> 2246 + {typedFields.map((field) => ( 2247 + <CopyPlaceholder 2248 + key={field.path} 2249 + value={`${f.name}.record.${field.path}`} 2250 + > 2251 + {field.description && ( 2252 + <span class={s.placeholderDesc}>{field.description}</span> 2253 + )} 2254 + </CopyPlaceholder> 2255 + ))} 2256 + </div> 2257 + ) : ( 2258 + <div class={s.placeholderGroup}> 2259 + <CopyPlaceholder value={`${f.name}.record.*`}> 2260 + <span class={s.placeholderDesc}>access nested fields</span> 2261 + </CopyPlaceholder> 2262 + </div> 2263 + )} 2264 + </div> 2265 + </details> 2266 + ); 2267 + })()} 2049 2268 </div> 2050 2269 ))} 2051 2270 <button type="button" class={s.addBtn} onClick={addFetch}>
+1
app/routes/api/automations/[rkey].ts
··· 64 64 $type: "run.airglow.automation#fetchStep", 65 65 name: f.name, 66 66 uri: f.uri, 67 + ...(f.collection ? { collection: f.collection } : {}), 67 68 ...(f.conditions ? { conditions: f.conditions } : {}), 68 69 ...(f.comment ? { comment: f.comment } : {}), 69 70 };
+5
lexicons/run/airglow/automation.json
··· 152 152 "description": "AT URI template, e.g. '{{event.commit.record.subject}}'.", 153 153 "maxLength": 2048 154 154 }, 155 + "collection": { 156 + "type": "string", 157 + "description": "Optional NSID hint for the fetched record's collection. Used only by the editor to surface typed placeholders; not enforced at runtime.", 158 + "maxLength": 256 159 + }, 155 160 "conditions": { 156 161 "type": "array", 157 162 "description": "Conditions evaluated against this fetch's result after it resolves. All must pass or the automation is skipped. Field paths are resolved relative to the fetch's entry (e.g. 'found', 'record.subject').",
+8
lib/actions/api-normalize.ts
··· 9 9 type FetchConditionInput, 10 10 } from "./validation.js"; 11 11 import { validateFetchStep } from "./template.js"; 12 + import { isValidNsid } from "../lexicons/resolver.js"; 12 13 import { AUTOMATION_LIMITS } from "../automations/limits.js"; 13 14 14 15 export type FetchInput = ··· 16 17 kind?: "record"; 17 18 name: string; 18 19 uri: string; 20 + collection?: string; 19 21 conditions?: FetchConditionInput[]; 20 22 comment?: string; 21 23 } ··· 67 69 if (!check.valid) return { ok: false, error: check.error }; 68 70 const condResult = validateFetchConditionInputs(step.conditions, step.name); 69 71 if (!condResult.valid) return { ok: false, error: condResult.error }; 72 + const collection = step.collection?.trim(); 73 + if (collection && !isValidNsid(collection)) { 74 + return { ok: false, error: `Invalid collection NSID for fetch '${step.name}'` }; 75 + } 70 76 seenNames.add(step.name); 71 77 names.push(step.name); 72 78 const entry: FetchStep = { 73 79 kind: "record", 74 80 name: step.name, 75 81 uri: step.uri, 82 + ...(collection ? { collection } : {}), 76 83 ...(condResult.value.length > 0 ? { conditions: condResult.value } : {}), 77 84 ...(step.comment ? { comment: step.comment } : {}), 78 85 }; ··· 81 88 $type: "run.airglow.automation#fetchStep", 82 89 name: step.name, 83 90 uri: step.uri, 91 + ...(collection ? { collection } : {}), 84 92 ...(condResult.value.length > 0 ? { conditions: condResult.value } : {}), 85 93 ...(step.comment ? { comment: step.comment } : {}), 86 94 });
+1
lib/automations/pds.ts
··· 96 96 $type: "run.airglow.automation#fetchStep"; 97 97 name: string; 98 98 uri: string; 99 + collection?: string; 99 100 conditions?: PdsCondition[]; 100 101 comment?: string; 101 102 };
+1
lib/db/schema.ts
··· 91 91 kind?: "record"; // absent on legacy rows — treated as "record" 92 92 name: string; 93 93 uri: string; // AT URI template 94 + collection?: string; // NSID hint for placeholder typing; not used at runtime 94 95 /** Conditions evaluated against this fetch's result entry after it resolves. 95 96 * If any fail, the automation is skipped silently. */ 96 97 conditions?: Condition[];