Ctrl K
ComunidadGuías…

lib/knowledge-db-validation.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
var MAX_NAME = 120;
var MAX_FIELDS = 100;
var MAX_RECORDS = 20000;
var MAX_VIEWS = 50;
var MAX_OPTIONS = 200;
var MAX_TEXT = 20000;
var TYPES = { title: true, text: true, number: true, boolean: true, date: true, select: true, "multi-select": true, reference: true };
var SAFE_ID = /^[A-Za-z0-9_-]{8,80}$/;
var UNSAFE_KEY = /^(?:__proto__|prototype|constructor)$/;

function fail(message, code) { throw Object.assign(new Error(message), { code: code || "INVALID" }); }
function text(value, label, max, allowEmpty) {
  if (typeof value !== "string") fail(label + " must be text.");
  var clean = value.trim();
  if (!allowEmpty && !clean) fail(label + " is required.");
  if (clean.length > (max || MAX_NAME)) fail(label + " is too long.");
  if (/[\u0000-\u001f\u007f]/.test(value)) fail(label + " contains unsafe characters.");
  return clean;
}
function id(value, label) {
  if (typeof value !== "string" || !SAFE_ID.test(value)) fail("A valid " + label + " is required.");
  return value;
}
function plainObject(value, label) {
  if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) fail(label + " must be an object.");
  Object.keys(value).forEach(function (key) { if (UNSAFE_KEY.test(key) || key[0] === "$" || key.indexOf(".") !== -1 || key.indexOf(",") !== -1) fail(label + " contains an unsafe key."); });
  return value;
}
function uniqueIds(values, label) {
  var seen = {};
  values.forEach(function (value) { if (seen[value]) fail(label + " contains duplicates."); seen[value] = true; });
}
function normalizeOptions(options) {
  if (!Array.isArray(options) || options.length > MAX_OPTIONS) fail("Select options are invalid.");
  var out = options.map(function (option) {
    plainObject(option, "Select option");
    return { id: id(option.id, "option id"), name: text(option.name, "Option name", 80, false), archived: option.archived === true };
  });
  uniqueIds(out.map(function (entry) { return entry.id; }), "Select options");
  return out;
}
function normalizeField(field) {
  plainObject(field, "Field");
  var type = String(field.type || "");
  if (!TYPES[type]) fail("Unsupported field type: " + type);
  var out = { id: id(field.id, "field id"), name: text(field.name, "Field name", MAX_NAME, false), type: type, required: field.required === true, archived: field.archived === true };
  if (type === "select" || type === "multi-select") out.options = normalizeOptions(field.options || []);
  if (type === "reference") {
    out.target = field.target === "text" ? "text" : field.target === "record" ? "record" : fail("Reference target must be text or record.");
    if (out.target === "record" && field.targetDatabaseId) out.targetDatabaseId = id(field.targetDatabaseId, "target database id");
  }
  return out;
}
function normalizeSchema(fields) {
  if (!Array.isArray(fields) || !fields.length || fields.length > MAX_FIELDS) fail("Schema must contain between 1 and " + MAX_FIELDS + " fields.");
  var out = fields.map(normalizeField); uniqueIds(out.map(function (field) { return field.id; }), "Schema");
  var titles = out.filter(function (field) { return field.type === "title" && !field.archived; });
  if (titles.length !== 1) fail("Schema must have exactly one active title field.");
  return out;
}
function fieldMap(schema) { var map = {}; schema.forEach(function (field) { map[field.id] = field; }); return map; }
function optionMap(field) { var map = {}; (field.options || []).forEach(function (option) { if (!option.archived) map[option.id] = true; }); return map; }
function dateValue(value) {
  if (typeof value !== "string" || value.length > 40 || !/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})?)?$/.test(value) || !Number.isFinite(Date.parse(value))) fail("Date value is invalid.");
  if (value.length === 10 && new Date(value + "T00:00:00Z").toISOString().slice(0, 10) !== value) fail("Date value is invalid.");
  return value;
}
function referenceValue(value, field, context) {
  plainObject(value, "Reference");
  var target = value.type === "text" ? "text" : value.type === "record" ? "record" : fail("Reference type is invalid.");
  if (target !== field.target) fail("Reference target does not match the field schema.");
  var out = { type: target, id: id(value.id, "reference id") };
  if (target === "record") {
    out.databaseId = id(value.databaseId || field.targetDatabaseId, "reference database id");
    if (field.targetDatabaseId && out.databaseId !== field.targetDatabaseId) fail("Reference database does not match the field schema.", "FORBIDDEN_REFERENCE");
  }
  if (context && typeof context.authorizeReference === "function" && !context.authorizeReference(out)) fail("Reference target is outside this Mate or does not exist.", "FORBIDDEN_REFERENCE");
  return out;
}
function normalizeValue(value, field, context) {
  if (value === null) { if (field.required) fail(field.name + " is required."); return null; }
  if (field.type === "text") {
    if (typeof value !== "string") fail(field.name + " must be text.");
    if (value.length > MAX_TEXT) fail(field.name + " is too long.");
    if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) fail(field.name + " contains unsafe characters.");
    if (field.required && !value.trim()) fail(field.name + " is required.");
    return value;
  }
  if (field.type === "title") return text(value, field.name, MAX_TEXT, !field.required);
  if (field.type === "number") { if (typeof value !== "number" || !Number.isFinite(value)) fail(field.name + " must be a finite number."); return value; }
  if (field.type === "boolean") { if (typeof value !== "boolean") fail(field.name + " must be true or false."); return value; }
  if (field.type === "date") return dateValue(value);
  if (field.type === "select") { var selected = id(value, "option id"); if (!optionMap(field)[selected]) fail(field.name + " uses an unknown or archived option."); return selected; }
  if (field.type === "multi-select") {
    if (!Array.isArray(value) || value.length > MAX_OPTIONS) fail(field.name + " must be an option list.");
    var options = optionMap(field); var values = value.map(function (entry) { var selected = id(entry, "option id"); if (!options[selected]) fail(field.name + " uses an unknown or archived option."); return selected; });
    uniqueIds(values, field.name); return values;
  }
  if (field.type === "reference") return referenceValue(value, field, context);
  fail("Unsupported field type.");
}
function normalizeValues(values, schema, context, existing) {
  plainObject(values, "Record values"); var fields = fieldMap(schema); var out = existing ? Object.assign({}, existing) : {};
  Object.keys(values).forEach(function (key) { if (!fields[key] || fields[key].archived) fail("Unknown or archived field: " + key); out[key] = normalizeValue(values[key], fields[key], context); });
  schema.forEach(function (field) { if (!field.archived && field.required && (out[field.id] === null || out[field.id] === undefined || out[field.id] === "")) fail(field.name + " is required."); });
  return out;
}
function assertCompatibleSchema(previous, next, records) {
  var before = fieldMap(previous); var after = fieldMap(next);
  Object.keys(before).forEach(function (fieldId) {
    var oldField = before[fieldId]; var newField = after[fieldId];
    if (!newField) fail("Fields must be archived instead of removed.", "DESTRUCTIVE_SCHEMA");
    var populated = records.some(function (record) { return record.values[fieldId] !== null && record.values[fieldId] !== undefined; });
    if (populated && oldField.type !== newField.type) fail("A populated field cannot change type.", "DESTRUCTIVE_SCHEMA");
    if (populated && oldField.type === "reference" && (oldField.target !== newField.target || oldField.targetDatabaseId !== newField.targetDatabaseId)) fail("A populated reference field cannot change target.", "DESTRUCTIVE_SCHEMA");
    if (oldField.type === "select" || oldField.type === "multi-select") {
      var options = {}; (newField.options || []).forEach(function (option) { options[option.id] = option; });
      (oldField.options || []).forEach(function (option) { if (!options[option.id]) fail("Select options must be archived instead of removed.", "DESTRUCTIVE_SCHEMA"); });
    }
    if (populated && !oldField.archived && newField.archived && newField.required) fail("An archived field cannot remain required.", "INVALID");
  });
  Object.keys(after).forEach(function (fieldId) {
    var field = after[fieldId]; var oldField = before[fieldId];
    if (field.archived || !field.required || !records.length) return;
    if (!oldField || !oldField.required) {
      var missing = records.some(function (record) { var value = record.values[fieldId]; return value === null || value === undefined || value === ""; });
      if (missing) fail("A required field cannot be added while existing records have no value.", "DESTRUCTIVE_SCHEMA");
    }
  });
}
function normalizeFilter(filter, schema) {
  plainObject(filter, "Filter"); var fields = fieldMap(schema); var field = fields[id(filter.fieldId, "filter field id")];
  if (!field || field.archived) fail("Filter field is unknown or archived.");
  var allowed = { eq: true, neq: true, contains: true, gt: true, gte: true, lt: true, lte: true, empty: true, "not-empty": true };
  var op = String(filter.op || ""); if (!allowed[op]) fail("Unsupported filter operator.");
  if ((op === "gt" || op === "gte" || op === "lt" || op === "lte") && field.type !== "number" && field.type !== "date") fail("Range filters require number or date fields.");
  if (op === "contains" && ["text", "title", "multi-select"].indexOf(field.type) === -1) fail("Contains is not valid for this field.");
  var value = null;
  if (op !== "empty" && op !== "not-empty") {
    if (op === "contains" && field.type === "multi-select") {
      var options = optionMap(field); value = id(filter.value, "option id"); if (!options[value]) fail(field.name + " uses an unknown or archived option.");
    } else value = normalizeValue(filter.value, field, null);
  }
  return { fieldId: field.id, op: op, value: value };
}
function normalizeSort(sort, schema) {
  plainObject(sort, "Sort"); var field = fieldMap(schema)[id(sort.fieldId, "sort field id")]; if (!field || field.archived) fail("Sort field is unknown or archived.");
  var direction = sort.direction === "desc" ? "desc" : sort.direction === "asc" ? "asc" : fail("Sort direction must be asc or desc."); return { fieldId: field.id, direction: direction };
}
function normalizeView(view, schema) {
  plainObject(view, "View"); var active = fieldMap(schema); var columns = (view.columns || []).map(function (fieldId) { var field = active[id(fieldId, "column field id")]; if (!field || field.archived) fail("View contains an unknown or archived column."); return field.id; });
  uniqueIds(columns, "View columns"); if (!columns.length || columns.length > MAX_FIELDS) fail("View needs at least one column.");
  var filters = (view.filters || []).map(function (filter) { return normalizeFilter(filter, schema); }); if (filters.length > 20) fail("View has too many filters.");
  var sorts = (view.sorts || []).map(function (sort) { return normalizeSort(sort, schema); }); if (sorts.length > 10) fail("View has too many sorts.");
  return { id: id(view.id, "view id"), name: text(view.name, "View name", MAX_NAME, false), type: "table", columns: columns, filters: filters, sorts: sorts, archived: view.archived === true };
}

module.exports = { MAX_FIELDS: MAX_FIELDS, MAX_RECORDS: MAX_RECORDS, MAX_VIEWS: MAX_VIEWS, MAX_TEXT: MAX_TEXT, TYPES: TYPES, id: id, text: text, plainObject: plainObject, normalizeSchema: normalizeSchema, normalizeValues: normalizeValues, assertCompatibleSchema: assertCompatibleSchema, normalizeView: normalizeView, normalizeFilter: normalizeFilter, normalizeSort: normalizeSort };