Source: utils/projectContract.js

// @ts-check
/**
 * Project/Share wire-format helpers for the Members service contract
 * (see 080-Workspaces/018-Cross-Service-Contracts.mdx).
 *
 * The internal storage follows the members model:
 * - Project:  ObjectBase row with Type='project', UIDBelongsTo=organization,
 *             Title/Display, Data = { description, metadata, ... }
 * - Share:    ObjectBase row with Type='repositoryShare'|'directoryShare',
 *             UIDBelongsTo=organization UID, Data = resource metadata
 * - Relation: Links rows with Type='memberA'|'member',
 *             UID=project, UIDTarget=share
 *             ('memberA' = write/admin, 'member' = read only)
 *
 * Share objects never carry an ACL and never reference secret values.
 */

import { HEX2uuid } from '@commtool/sql-query';

/** @typedef {'repositoryShare'|'directoryShare'} ShareType */

export const SHARE_TYPES = /** @type {const} */ (['repositoryShare', 'directoryShare']);

/** MariaDB temporal history guarantee (contract: 13 years). */
export const RETENTION_YEARS = 13;

/**
 * Format a DB timestamp (Date, string or number) as RFC3339 with microsecond
 * precision (the contract's API time format).
 * @param {Date|string|number} value
 * @returns {string}
 */
export const toRfc3339Micro = (value) => {
    const date = value instanceof Date ? value : new Date(value);
    if (Number.isNaN(date.getTime())) {
        return '1970-01-01T00:00:00.000000Z';
    }
    const iso = date.toISOString(); // YYYY-MM-DDTHH:mm:ss.sssZ
    const micros = String(date.getMilliseconds()).padStart(3, '0') + '000';
    return iso.replace(/\.\d{3}Z$/, `.${micros}Z`);
};

/**
 * Map an ObjectBase row to the contract Project object.
 * @param {Object} row - raw ObjectBase/Member row (UUID cast applied)
 * @returns {Object}
 */
export const projectToContract = (row) => ({
    uid: row.UID,
    organization_uid: row.organization_uid || row.UIDBelongsTo,
    group_uid: row.group_uid || null,
    title: row.Title || row.Display || '',
    description: row.description ?? null,
    metadata: row.metadata ?? {},
    source_updated_at: toRfc3339Micro(row.source_updated_at ?? row.ValidFrom ?? new Date()),
});

/**
 * Map an ObjectBase row to the contract Share object. The share belongs to the
 * org, so the contract `project_uid` is taken from the project context that
 * resolved the share (via memberA/member link), never from UIDBelongsTo.
 * @param {Object} row - raw ObjectBase row (UUID cast applied)
 * @param {string} [projectUid] - project that links this share
 * @returns {Object}
 */
export const shareToContract = (row, projectUid = null) => ({
    uid: row.UID,
    project_uid: projectUid || row.project_uid || null,
    type: row.Type,
    metadata: row.metadata ?? {},
    source_updated_at: toRfc3339Micro(row.source_updated_at ?? row.ValidFrom ?? new Date()),
});

/**
 * Extract the metadata object from a project row's `metadata_json` envelope
 * (`JSON_OBJECT('value', JSON_EXTRACT(Data, '$.metadata'))`).
 *
 * With `{ cast: ['json'] }` the driver already parses the envelope into an
 * object (`{ value: {...} }`), while legacy/test call paths may still deliver a
 * JSON string. Both forms are normalized here; anything else yields `{}`.
 * @param {Object} row
 * @returns {Object}
 */
export const metadataFromRow = (row) => {
    let envelope = row && row.metadata_json;
    if (!envelope) return {};
    if (typeof envelope === 'string') {
        try {
            envelope = JSON.parse(envelope);
        } catch {
            return {};
        }
    }
    const value = envelope && typeof envelope === 'object' ? envelope.value : null;
    return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
};

/**
 * Map a raw ObjectBase row to the contract Share object for a share that also
 * carries a memberA/member link (used by snapshot reads over system time).
 * @param {Object} row
 * @param {string} projectUid
 * @param {string} [linkType] - 'memberA'|'member'
 * @returns {Object}
 */
export const shareLinkToContract = (row, projectUid, linkType = null) => {
    let data = row.Data;
    if (typeof data === 'string') {
        try {
            data = JSON.parse(data);
        } catch {
            data = null;
        }
    }
    return {
        uid: HEX2uuid(row.UID),
        project_uid: projectUid,
        type: row.Type,
        linkType: linkType === 'memberA' ? 'write' : linkType === 'member' ? 'read' : null,
        metadata: data && typeof data === 'object' ? data.metadata ?? {} : {},
        source_updated_at: toRfc3339Micro(row.ValidFrom),
    };
};