Object.defineProperty(exports, '__esModule', { value: true }); // The fetcher that is passed should mimic the native fetch method. /** * A typed `fetch` wrapper parsing JSON responses by default. * @param url URL of the resource to send a request to. * @param init Optional `RequestInit` containing the request body, headers, and other details. * @returns Body of the received `Response`. */ const $fetch = async (url, init, fetcher = fetch)=>fetcher(url, init).then((res)=>res.json()); /** * Dynamically construct a combined object property accessor. * @param parent Parent property accessor. * @param child Property accessor to be appended. * @returns Combined object property accessor. */ const combineKeys = (parent, child)=>{ // In the case that `child` contains `.`, it's a nested field such as // `invoice.companyName` and in those cases, we have to prevent `invoice` // from being expanded into an object later on, which is accomplished using // the `//` escaping. const nested = child.replaceAll('.', '\\.'); return parent ? `${parent}.${nested}` : nested; }; /** * Construct a new object from a "dot string" property accessor. * @param accessor Accessor to construct object from. * @param value Value to set the given accessor to. * @returns Object constructed from the given accessor and value. */ const objectFromAccessor = (accessor, value)=>setProperty({}, accessor, value); /** * Recursive function iterating through the values of a given map to find a safe index to add new items to. * @param map Map to look for a free index inside to add items to. * @param index Index to start looking at, incremented with each iteration. * @returns First available index. */ const findSafeIndex = (map, index)=>{ const values = Array.from(map.values()); const existing = values.find((item)=>item[0] === index); if (existing) { return findSafeIndex(map, index + 1); } return index; }; /** * This function splits the given path into an array of so-called segments. * This is done by splitting the path on the `.` character, but only if it's * not preceded by a `\` character. This is done to allow for setting * values on nested records, such as `invoice.companyName`. * @param path Dot-separated path to split into segments. */ const getPathSegments = (path)=>{ const segments = path// Split path on property and array accessors (`.` and `[]`). By using a // non-printable unicode character (u200B), we can achieve the same result // as when using a negative lookbehind to filter out preceding backslashes, // but with support for Safari, because negative lookbehinds don't work // in Safari at the time of writing. .replace(/\\\./g, '\u200B').split(/[[.]/g).map((s)=>s.replace(/\u200B/g, '.'))// Filter out empty values. (`foo..bar` would otherwise result in // `['foo', '', 'bar']`). .filter((x)=>!!x.trim())// Remove the escaping character (`\`) before escaped segments. // `foo\.bar,baz` will result in `['foo.bar', 'baz']`. .map((x)=>x.replaceAll('\\.', '.')); return segments; }; /** * Set the property at the given path to the given value. * @param obj Object to set the property on. * @param path Path of the property to set. * @param value Value to set at the given path. * @param overrideExisting If `true`, existing parent object will be replaced with a new empty object. * @returns Object with the updated property. * * Originally from https://github.com/rayepps/radash/blob/7c6b986d19c68f19ccf5863d518eb19ec9aa4ab8/src/object.ts#L240-L264. * * This custom `setProperty` function differs significantly from the original * set` function in `radash` in that it doesn't support fallbacks and always * expects the path's target not to exist. */ const setProperty = (obj, path, value, overrideExisting = true)=>{ const segments = getPathSegments(path); const set = (node)=>{ if (segments.length > 1) { const key = segments.shift(); node[key] = overrideExisting || !Object.prototype.hasOwnProperty.call(node, key) ? {} : node[key]; set(node[key]); } else { node[segments[0]] = value; } }; set(obj); return obj; }; /** * Gets the property value of an object based on the given dot-separated path. * * @param obj The object to get the property value from. * @param path The dot-separated path string of the property. * @returns The property value at the specified path or `undefined` if the path does not exist. * * @example * const exampleObject = { * user: { * name: { * first: 'John', * last: 'Doe' * }, * age: 30 * } * }; * console.log(getProperty(exampleObject, 'user.name.first')); // Output: 'John' * console.log(getProperty(exampleObject, 'user.age')); // Output: 30 * console.log(getProperty(exampleObject, 'user.non.existing')); // Output: undefined */ const getProperty = (obj, path, defaultValue = undefined)=>{ const segments = getPathSegments(path); let current = obj; for (const key of segments){ if (current[key] === null) return defaultValue; if (current[key] === undefined) return defaultValue; current = current[key]; } return current; }; /** * Turn the given string into "dash-case", which we use for slugs. * @param string String to turn into dash-case. * @returns String compatible with "dash-case". * * Originally from https://github.com/rayepps/radash/blob/7c6b986d19c68f19ccf5863d518eb19ec9aa4ab8/src/string.ts#L60-L71. */ const toDashCase = (string)=>{ const capitalize = (str)=>{ const lower = str.toLowerCase(); return lower.substring(0, 1).toUpperCase() + lower.substring(1, lower.length); }; const parts = string?.replace(/([A-Z])+/g, capitalize)?.split(/(?=[A-Z])|[.\-\s_]/).map((x)=>x.toLowerCase()) ?? []; if (parts.length === 0) return ''; if (parts.length === 1) return parts[0]; return parts.reduce((acc, part)=>`${acc}-${part.toLowerCase()}`); }; /** * Extracts `Readable`s from a set of given Queries. * @param queries `Query` array to look for `Readable`s in. * @returns `Readable` array ready for further processing. */ const extractReadables = (queries)=>queries.reduce((references, query, queryIndex)=>{ return [ ...references, ...Object.entries(query).reduce((references, [queryType, query])=>{ // Abort if the `queryType` is not one of `set` or `create`. if (![ 'set', 'create' ].includes(queryType)) return references; return [ ...references, ...Object.entries(query).reduce((references, [schema, instructions])=>{ // Access the query instructions according to the query type. const fields = instructions[queryType === 'set' ? 'to' : 'with']; return [ ...references, ...Object.entries(fields).reduce((references, [name, value])=>{ // Identify values which are either an instance of a `File` or // an instance of a `ReadableStream`. if (!(typeof File === 'function' && value instanceof File) && !(value instanceof ReadableStream)) return references; return [ ...references, { query: { index: queryIndex, type: queryType }, schema, field: name, value, contentType: typeof File === 'function' && value instanceof File ? value.type : 'application/octet-stream' } ]; }, []) ]; }, []) ]; }, []) ]; }, []); /** * Transform a given `ScratchPad` into `Queries` (array) which can be passed to RONIN. * @param scratchPad `ScratchPad` to get executable `Queries` from. * @returns `Queries` which will be passed to RONIN. */ const getQueriesFromScratchPad = (scratchPad)=>{ const queries = []; const queriesWithoutSetters = new Map(); const getValidator = (queryType)=>{ const validator = { get (target, key) { const freshValidator = Object.assign({}, validator); freshValidator.id = this.id || crypto.randomUUID(); freshValidator.parentKey = combineKeys(this.parentKey, key); // Determine the index on which the raw `get` query should be placed in the final list of queries. // By default, it'll be after the last query (so the length of `queries`), but since there // might be multiple raw `get` queries, we have to make sure we're not assigning all of them to the // same index in the final list of queries. const queriesWithoutSettersIndex = findSafeIndex(queriesWithoutSetters, queries.length); queriesWithoutSetters.set(freshValidator.id, [ queriesWithoutSettersIndex, { accessor: freshValidator.parentKey, queryType } ]); return new Proxy(target[key] || {}, freshValidator); }, set (target, key, value) { const acessor = combineKeys(this.parentKey, key); const expanded = objectFromAccessor(acessor, value); queriesWithoutSetters.delete(this.id); if ([ 'get', 'set', 'drop', 'create', 'count' ].includes(queryType)) { queries.push({ [queryType]: expanded }); } return true; } }; return validator; }; const getProxy = (type)=>new Proxy({}, getValidator(type)); scratchPad({ get: getProxy('get'), set: getProxy('set'), drop: getProxy('drop'), create: getProxy('create'), count: getProxy('count') }); for (const [index, details] of Array.from(queriesWithoutSetters.values())){ const { queryType , accessor } = details; queries.splice(index, 0, objectFromAccessor(`${queryType}.${accessor}`, null)); } return queries; }; const storeReadables = async (readables, token, options = {})=>{ const requests = readables.map(({ value , contentType }, index)=>$fetch(options.endpoint || 'https://data.ronin.co/writable', { method: options.method || 'PUT', body: value, headers: { 'Content-Type': contentType, Authorization: `Bearer ${token}` } }, options?.fetcher).then((reference)=>({ ...readables[index], reference }))); // TODO: Handle Errors. return Promise.all(requests); }; /** * ## 🚧 For internal use only! 🚧 * * Store `Readable`s from given queries and populate the queries with the respective references. * @param queries `Query` array which may or may not include `Readable`s. * @param options `ReadableProcessingOptions` object containing options passed on to the internal handling of `Readable`s. * @returns Queries populated with the references to each `Readable` they might have initially contained. */ const processReadables = async (queries, token, options = {})=>{ const readables = extractReadables(queries); if (readables.length > 0) { const storedReadables = await storeReadables(readables, token, options); for (const readable of storedReadables){ const { query , schema , field , reference } = readable; queries[query.index][query.type][schema][query.type === 'set' ? 'to' : 'with'][field] = reference; } } return queries; }; const formatField = (field, fieldType)=>{ switch(fieldType){ case 'time': if (typeof field === 'number' || typeof field === 'string') return new Date(field); return field; default: return field; } }; const formatRecord = (record, schema)=>{ const clonedRecord = structuredClone(record); Object.entries(schema).forEach(([key, fieldType])=>{ const formattedField = formatField(getProperty(clonedRecord, key), fieldType); setProperty(clonedRecord, key, formattedField, false); }); return clonedRecord; }; /** * Run a set of given queries. * @param queries `Query` array containing queries to run – these queries can contain `Readable`s. * @param options `RunScratchpadOptions` object containing options passed on to the internal `Readable` and `Query` processing. * @returns Promise resolving the queried data. */ const runQueries$1 = async (queries, options = {})=>{ const url = new URL(options.queryProcessingOptions?.endpoint || 'https://data.ronin.co'); const dataSelector = options.queryProcessingOptions?.dataSelector; if (dataSelector) { url.searchParams.set('data-selector', dataSelector); } const { results , error , metrics } = await $fetch(url.href, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${options.token}` }, body: JSON.stringify({ queries }) }, options?.queryProcessingOptions?.fetcher); if (error) { throw new Error(error.message); } const res = results.map((result)=>{ if ('error' in result && result.error) { throw new Error(result.error.message); } if ('record' in result && typeof result.record !== 'undefined') { // `result.record` comes as null when no record was found. if (result.record === null) return null; return formatRecord(result.record, result.schema); } if ('amount' in result && typeof result.amount !== 'undefined' && result.amount !== null) { return result.amount; } if ('records' in result) { result.records = result.records.map((record)=>formatRecord(record, result.schema)); if (typeof result.moreAfter !== 'undefined') { // Expose the pagination cursor in order to allow // for retrieving the next page. // This value is already available on `result`, // but since we're only returning `result.records`, // we want it to be set on that array. result.records.moreAfter = result.moreAfter; } } return result.records; }); res.metrics = metrics; return res; }; const EMPTY = Symbol('empty'); const getSchema = (instruction)=>{ const key = Object.keys(instruction)[0]; let schema = String(key); let multipleRecords = false; if (schema.endsWith('s')) { schema = schema.substring(0, schema.length - 1); multipleRecords = true; } return { key, // Convert camel case (e.g. `subscriptionItems`) into slugs (e.g. `subscription-items`). schema: toDashCase(schema), multipleRecords }; }; /** * Constructs the method name used for a particular type of hook and query. * For example, if `hookType` is "after" and `queryType` is "create", the * resulting method name would be `afterCreate`. * @param hookType The type of hook, so "before", "during", or "after". * @param queryType The type of query. For example: "get", "set", or "drop". * @returns The method name constructed from the hook and query types. */ const getMethodName = (hookType, queryType)=>{ const capitalizedQueryType = queryType[0].toUpperCase() + queryType.slice(1); return hookType === 'during' ? queryType : hookType + capitalizedQueryType; }; /** * Based on which type of query is being executed (e.g. "get" or "create"), * this function checks if a hook is defined for the affected schema and runs * said hook with several arguments (such as the query that was run). * For example, if `create.account.with.id = '1234';` is run and the `hookType` * is `before`, then the `beforeCreate` hook would be invoked if one is defined * for the "account" schema in the list of hooks. * @param hooks A map of all schemas with their respective hooks. * @param hookType The type of hook, so "before", "during", or "after". * @param query A deconstructed query for which the hook should be run. * @returns Information about whether a hook was run, and its potential output. */ const invokeHook = async (hooks, hookType, query)=>{ const hooksForSchema = hooks?.[query.schema]; const hookName = getMethodName(hookType, query.type); // If `oldInstruction` is falsy (e.g. `null`), we want to default to `{}`. // This would happen in cases where all records of a particular schema are // retrieved. For example, the query `get.members;` would trigger this. // It's important to provide an object to hooks, as people might otherwise // try to set properties on a value that's not an object, which would cause // the hook to crash with an exception. // It's also extremely important to clone both of the variables below, as the // hooks will otherwise modify the original that was passed from the outside. const queryInstruction = query.instruction ? structuredClone(query.instruction) : {}; // A list of function arguments that will be passed to every data hook, // regardless of its type or schema. const hookArguments = [ queryInstruction, query.plural ]; // For data hooks of type "after" (such as `afterCreate`), we want to pass // a special function argument that contains the result of the query. if (hookType === 'after') { const queryResult = structuredClone(query.result); hookArguments.push(queryResult); } if (hooksForSchema?.[hookName]) { const result = await hooksForSchema[hookName](...hookArguments); return { ran: true, result }; } return { ran: false, result: null }; }; /** * Invokes a particular hook (such as `afterCreate`) and handles its output. * In the case of an "before" hook, a query is returned from the hook, which * must replace the original query in the list of queries. For a "during" hook, * the results of the query are returned and must therefore be merged into the * final list of results. In the case of an "after" hook, nothing must be done * because no output is returned by the hook. * @param hooks A map of all schemas with their respective hooks. * @param hookType The type of hook, so "before", "during", or "after". * @param modifiableQueries The final list of queries. * @param modifiableResults The final list of query results. * @param query The definition and other details of a query that should be run. * @returns Nothing, because `modifiableQueries` and `modifiableResults` are * directly modified instead. */ const invokeHooks = async (hooks, hookType, modifiableQueries, modifiableResults, query)=>{ const queryType = Object.keys(query.definition)[0]; const queryInstructions = query.definition[queryType]; const { key , schema , multipleRecords } = getSchema(queryInstructions); const oldInstruction = queryInstructions[key]; const executedHookResults = await invokeHook(hooks, hookType, { type: queryType, schema, plural: multipleRecords, instruction: oldInstruction, // For "after" hooks, we want to pass the final result associated with a // particular query, so that the hook can read it. result: hookType === 'after' ? query.result : null }); // We can't assert based on what the hook returned, only based on whether the // hook ran or not. That's because a hook might return any falsy value and // then we would be mislead into thinking that the hook didn't run. const { ran , result: hookResult } = executedHookResults; switch(hookType){ case 'before': if (!ran) break; queryInstructions[key] = hookResult; modifiableQueries[query.index] = { [queryType]: queryInstructions }; break; case 'during': if (ran) { // In the case of "during" hooks, we don't want to keep the part of the // scratchpad that's responsible for querying a particular schema, // because queries of that schema would be entirely handled by the // "during" hooks. That's why we're deleting the query from the // scratchpad here. We can't use `splice` here as that would change the // array position of future items that we haven't yet iterated over, so // they would not be able to splice themselves correctly, as their // index in the iterator would not match their real index. modifiableQueries[query.index] = EMPTY; // The hook returned a record (or multiple), so we'd like to add those // records to the list of final results. modifiableResults[query.index] = hookResult; } else { // In the case that the hook didn't run and we're dealing with "during" // hooks, we still want to add an empty item to the list, so that the // indexes of results being added afterwards are correct. We have to // use a symbol instead of something like `undefined`, because a hook // might return `undefined` so we wouldn't know whether the array item // is a result or just an empty placeholder. modifiableResults[query.index] = EMPTY; } break; } }; /** * Executes queries and also invokes any potential hooks (such as `afterCreate`) * that might have been provided as part of `options.hooks`. * @param queries A list of queries to execute. * @param options A list of options to change how the queries are executed. To * run hooks, the `options.hooks` property must contain a map of hooks. * @returns The results of the queries that were passed. */ const runQueriesWithHooks = async (queries, options = {})=>{ let modifiableQueries = Array.from(queries); const modifiableResults = new Array(); const { hooks , waitUntil } = options; // We're intentionally considering the entire `hooks` option here, instead of // searching for "after" hooks inside of it, because the latter would increase // the error surface and make the logic less reliable. if (typeof process === 'undefined' && hooks && !waitUntil) { let message = 'In the case that the "ronin" package receives a value for'; message += ' its `hooks` option, it must also receive a value for its'; message += ' `waitUntil` option. This requirement only applies when using'; message += ' an edge runtime and ensures that the edge worker continues to'; message += ' execute until all "after" hooks have been executed.'; throw new Error(message); } // Invoke `beforeGet`, `beforeSet`, `beforeDrop`, `beforeCount` and `beforeCreate`. await Promise.all(queries.map((query, index)=>{ return invokeHooks(hooks, 'before', modifiableQueries, modifiableResults, { definition: query, index, result: null }); })); // Invoke `get`, `set`, `drop`, `count` and `create`. await Promise.all(queries.map((query, index)=>{ return invokeHooks(hooks, 'during', modifiableQueries, modifiableResults, { definition: query, index, result: null }); })); // Filter out queries that were marked as removable by the `invokeHooks` // calls above. We can't just filter by something like `undefined`, because // hooks might be returning those values as successful results. modifiableQueries = modifiableQueries.filter((query)=>query !== EMPTY); // If no queries are remaining, that means all the queries were handled by // "during" hooks above, so there are none remaining to send for execution. if (modifiableQueries.length === 0) return modifiableResults; const results = await runQueries$1(modifiableQueries, options); for(let index = 0; index < modifiableResults.length; index++){ const existingResult = modifiableResults[index]; // If there isn't an existing result for the current index, that means // `results` will contain the result for that index. However, note that // the indexes in `results` are different because they don't consider the // results that weren't retrieved from RONIN and only retrieved from // "during" hooks. if (existingResult === EMPTY) { continue; } results.splice(index, 0, existingResult); } // Invoke `afterGet`, `afterSet`, `afterDrop`, `afterCount` and `afterCreate` // (asynchronously, since they shouldn't block). for(let queryIndex = 0; queryIndex < queries.length; queryIndex++){ const query = queries[queryIndex]; const queryType = Object.keys(query)[0]; // "after" hooks should only fire for writes — not reads. if (queryType !== 'set' && queryType !== 'drop' && queryType !== 'create') { continue; } // Select the results that are associated with the current query. const queryResult = results[queryIndex]; // Run the actual hook functions. const promise = invokeHooks(hooks, 'after', queries, null, { definition: query, index: queryIndex, result: queryResult }); // If the configuration option for extending the lifetime of the edge // worker invocation was passed, provide it with the resulting promise of // the hook invocation above. This will ensure that the worker will // continue to be executed until all of the asynchronous actions // (non-awaited promises) have been resolved. if (waitUntil) waitUntil(promise); } return results; }; /** * Run a `ScratchPad`. * @param scratchPad `ScratchPad` to run. * @param options `RunScratchpadOptions` object containing options passed on to the internal `Readable` and `Query` processing. * @returns Promise resolving the queried data. */ const runScratchpad = async (scratchPad, options = {})=>{ const queries = getQueriesFromScratchPad(scratchPad); if (typeof process !== 'undefined') { options.token = options.token || process.env?.RONIN_TOKEN; if (!options.token) { let message = 'Please specify the `RONIN_TOKEN` environment variable'; message += ' or set the `token` option when invoking RONIN.'; throw new Error(message); } } if (!options.token) { let message = 'When invoking RONIN from an edge runtime, the'; message += ' `token` option must be set.'; throw new Error(message); } // Extract and process `Readable`s, if any are present. // `queriesPopulatedWithReferences` are the given `queries`, just that any `Readable` they might contain // has been processed and the value of the field has been replaced with the reference to the stored `Readable`. // This way, we only store the `reference` of the `Readable` inside the database for faster performance. ⚡ const queriesPopulatedWithReferences = await processReadables(queries, options.token, options.readableProcessingOptions); // If no queries were provided as part of the scratchpad, we don't want to // continue. This allows for using `if` and `return` statements inside the // scratchpad to run queries conditionally. However, it's extremely important // that this code is only used for `runScratchpad` and not `runQueries`, in // order to avoid people getting confused why `runQueries` is silently not // returning any results. For scratchpads, this would be expected and wanted. if (queriesPopulatedWithReferences.length === 0) return []; return runQueriesWithHooks(queriesPopulatedWithReferences, options); }; const runQueries = runQueriesWithHooks; /** * A factory producing functions for executing scratchpads. * @param config Configuration for all the scratchpads that will be executed with the function returned by the factory. * @returns A function for executing a scratchpad, which can be used multiple times. */ const createScratchpadFactory = (config)=>{ return (scratchPad)=>runScratchpad(scratchPad, config); }; function handleScratchPadOrConfig(...args) { const [scratchPadOrConfig, config] = args; if (typeof scratchPadOrConfig === 'function') return runScratchpad(scratchPadOrConfig, config); if (typeof scratchPadOrConfig === 'undefined' || typeof scratchPadOrConfig === 'object' && !Array.isArray(scratchPadOrConfig) && scratchPadOrConfig !== null) return createScratchpadFactory(scratchPadOrConfig); let message = 'The function exported by the "ronin" package must receive either'; message += ' a scratchpad (function) or an initial configuration (object).'; throw new Error(message); } exports.default = handleScratchPadOrConfig; exports.getQueriesFromScratchPad = getQueriesFromScratchPad; exports.processReadables = processReadables; exports.runQueries = runQueries;