洪 民憙 (Hong Minhee) :nonbinary:'s avatar

洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · 1004 following · 1443 followers

An intersectionalist, feminist, and socialist living in Seoul (UTC+09:00). @tokolovesme's spouse. Who's behind @fedify, @hollo, and @botkit. Write some free software in , , , & . They/them.

서울에 사는 交叉女性主義者이자 社會主義者. 金剛兔(@tokolovesme)의 配偶者. @fedify, @hollo, @botkit 메인테이너. , , , 等으로 自由 소프트웨어 만듦.

()

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Hello, I'm an open source software engineer in my late 30s living in , , and an avid advocate of and the .

I'm the creator of @fedify, an server framework in , @hollo, an ActivityPub-enabled microblogging software for single users, and @botkit, a simple ActivityPub bot framework.

I'm also very interested in East Asian languages (so-called ) and . Feel free to talk to me in , (), or (), or even in Literary Chinese (, )!

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to 洪 民憙 (Hong Minhee) :nonbinary:'s post

安寧(안녕)하세요, 저는 서울에 살고 있는 30() 後半(후반) 오픈 소스 소프트웨어 엔지니어이며, 自由(자유)·오픈 소스 소프트웨어와 聯合宇宙(연합우주)(fediverse)의 熱烈(열렬)支持者(지지자)입니다.

저는 TypeScript() ActivityPub 서버 프레임워크인 @fedify 프로젝트와 싱글 유저() ActivityPub 마이크로블로그인 @hollo 프로젝트와 ActivityPub 봇 프레임워크인 @botkit 프로젝트의 製作者(제작자)이기도 합니다.

저는 ()아시아 言語(언어)(이른바 )와 유니코드에도 關心(관심)이 많습니다. 聯合宇宙(연합우주)에서는 國漢文混用體(국한문 혼용체)를 쓰고 있어요! 제게 韓國語(한국어)英語(영어), 日本語(일본어)로 말을 걸어주세요. (아니면, 漢文(한문)으로도!)

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to 洪 民憙 (Hong Minhee) :nonbinary:'s post

こんにちは、私はソウルに住んでいる30代後半のオープンソースソフトウェアエンジニアで、自由・オープンソースソフトウェアとフェディバースの熱烈な支持者です。名前は洪 民憙ホン・ミンヒです。

私はTypeScript用のActivityPubサーバーフレームワークである「@fedify」と、ActivityPubをサポートする1人用マイクロブログである 「@hollo」と、ActivityPubのボットを作成する為のシンプルなフレームワークである「@botkit」の作者でもあります。

私は東アジア言語(いわゆるCJK)とUnicodeにも興味が多いです。日本語、英語、韓国語で話しかけてください。(または、漢文でも!)

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to Jiajun Xu's post

@foolfitz @fedify 謝謝!

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to Brandon Zhang 🇨🇳's post

@heybran @fedify 謝謝!

Mastodon's avatar
Mastodon

@Mastodon@mastodon.social

📢 We've sat down with our artist @dopatwo and created a sticker pack for @signalapp. Now you can send cute elephants to your friends, and promote the at the same time. We ❤️ Signal, too!

signal.art/addstickers/#pack_i

A purple image with a number of cute Mastodon mascots in a series of poses (running, this is fine, waiting, waving, greeting, boosting, liking, and typing)
ALT text detailsA purple image with a number of cute Mastodon mascots in a series of poses (running, this is fine, waiting, waving, greeting, boosting, liking, and typing)
youknowone's avatar
youknowone

@youknowone@hackers.pub

오픈소스 프로젝트에 여러분의 gemini cli(등등)의 무료 사용량을 기여하세요

오픈소스 소프트웨어라는 소프트웨어 개발 방법은 그동안 대성공을 거두어 오고 있습니다. 여기에는 여러 요인이 있지만, 중요한 요인 중 하나는 이것입니다. 상업 소프트웨어든 오픈소스 소프트웨어든 공평하게 프로그래머의 시간을 들인 만큼 개발된다는 것이지요. 능력 있는 소프트웨어 개발자가 시간을 기여하면 오픈소스 소프트웨어는 상업 소프트웨어만큼이나 빠르게 성장할 수 있었습니다.

하지만 AI 프로그래밍의 시대가 빠르게 다가오고 있습니다. 앞으로 소프트웨어 개발은 프로그래머의 시간만으로 개발되지 않습니다. 상업소프트웨어는 AI 프로그래밍을 적극적으로 사용하여 이전과 다른 생산성으로 개발되기 시작할 것입니다. 상업 소프트웨어와 달리 오픈소스 소프트웨어는 언제나 그럴 수는 없습니다. 프로젝트의 성장과 유지를 위해 훌륭한 프로그래머들의 시간을 들이는 것을 넘어서, 훌륭한 프로그래머들이 시간에 더해 비용까지 들여야 한다면요.

상업 소프트웨어와 오픈소스 소프트웨어 사이의 불균등한 생산성의 시대가 코앞까지 다가오고 있습니다.

새로운 기여자 확보의 문제

문제는 여기서 그치지 않습니다. 오픈소스 프로젝트는 새 기여자를 얻기 더 힘들어져가고 있습니다. 왜냐하면 이제 'good first issue'라는 것은 의미가 없기 때문입니다. 그 정도로 쉬운 일은 새로운 기여자 대신 로봇이 해결할 가능성이 높고, 그 로봇은 새로운 기여자의 로봇일 수도 있습니다. 결국 AI 프로그래밍으로 기여하는 새 기여자는 이 프로젝트에 대해 거의 배우지 못하게 됩니다.

전통적인 오픈소스 생태계에서 'good first issue'는 단순히 쉬운 문제를 해결하는 것이 아니었습니다. 새로운 기여자가 프로젝트의 코드베이스를 이해하고, 개발 프로세스를 익히며, 커뮤니티와 소통하는 법을 배우는 학습 과정이었습니다. 하지만 AI가 이런 단순한 작업들을 대신 처리하게 되면, 새로운 기여자들은 진입 기회를 잃게 됩니다.

AI 프로그래밍의 현재 위치

AI 프로그래밍은 완벽하지 않습니다. 숙련된 전문가가 숙련된 도메인에서 작업하는 것만큼 잘하지는 못합니다. 하지만 비숙련된 프로그래머가 처음 보는 프로젝트에서 작업하는 것보다는 잘할 때가 많습니다.

그러나 많은 오픈소스 소프트웨어는 바로 이런 비숙련 기여가 성장의 한 축을 차지합니다. 처음 프로젝트에 참여하는 개발자들의 작은 기여들이 모여 거대한 프로젝트가 됩니다. 그리고 이런 비숙련 기여의 일부는 손쉽게 AI가 대체할 수 있는 기여입니다.

다행히도 지금은 AI 프로그래밍의 초창기입니다. Gemini CLI가 무료 사용량을 제공하듯이, 앞으로 여러 회사들이 비슷한 기회를 제공할 것입니다. Claude, ChatGPT, Copilot 등 다양한 AI 도구들이 개인 사용자에게 무료 크레딧을 제공하고 있습니다.

이것은 오픈소스 프로젝트에 기여할 새로운 기회로 삼을 수 있을까요?

주의: 이 글은 아무 프로젝트에나 방문해서 AI로 적당한 코드를 생성한 다음 패치를 보내라는 뜻이 아닙니다.

AI 프로그래밍은 (아직은) 마법이 아닙니다. "이 프로젝트를 겁나 멋지게 만들 기능을 추가해주세요"라고 한다고 해서 그런 패치가 나오는 식으로는 동작하지 않습니다.

이상적인 경우: AI 친화적 프로젝트

가장 좋은 방법은 프로젝트가 AI 친화적으로 준비되는 것입니다. 바로 작업할 수 있을 만큼 잘 정의된 이슈들이 있는 프로젝트라면, "nnn 번 이슈에 대해 작업해 주세요"라는 요청만으로도 누구나 기여할 수 있을 것입니다.

하지만 (적어도 아직은) 그런 프로젝트가 많지는 않을 것입니다.

현실적인 접근: AI가 잘하는 일들에 집중

대신 AI는 인간과 비대칭적으로 잘하는 기능이 있습니다.

이를테면 이슈에 minimal reproducible case가 보고되어 있지만 아직 구체적으로 발생하는 원인이 밝혀져 있지 않은 경우를 생각해봅시다. 버그를 고치는 사람이 해야하는 지루한 작업 가운데 하나는, 이 문제를 어떻게 수정할지를 생각하기에 앞서 이 문제가 어디서 발생하는지 찾는 것입니다. 디버거를 써야 할 수도 있고, 코드에 많은 trace log를 남겨야 할 수도 있습니다.

하지만 AI 코딩 에이전트는 테스트가 재현 가능하기만 하다면, 문제를 발생시키는 정확한 줄을 찾아내는 데 탁월합니다. 지치지 않고 정석적인 지루한 방법으로 꾸준히 로그를 추가하고 테스트를 다시 실행하면서 문제를 찾아내거든요.

어쩌면 문제의 원인이 아주 단순해서, 문제를 바로 수정할 수 있을지도 모릅니다! 그렇다면 패치를 제출해도 좋겠지요. 하지만 바로 수정하기까지는 어렵더라도 괜찮습니다. 버그 리포트와 실제 코드의 문제를 매핑하는 것은 그 자체로 지루하고 시간이 걸리는 일입니다. 이것을 대신하는 것으로도 큰 작업을 대신하는 것입니다.

주의: 모든 프로젝트가 AI 기여를 환영할 리는 없습니다. 충분히 유용하게 다듬어지지 못한 유형의 AI 기여는 스팸처럼 느껴질 가능성이 있음을 유의해야 합니다.

미래

사실 누구나 자기 라이브러리를 뚝딱 만들어낼 수 있게 되었다는 점에서 오픈소스 프로젝트에 참여하는 사람들의 동기와 기여 방식 자체가 크게 뒤바뀔 가능성이 높습니다.

AI 프로그래밍을 누구나 거의 무료로 사용할 수 있는 시대가 올까요? 아마 어느 정도의 사용량까지는 그럴 것입니다. 그것이 얼마나 많은 양일지에 따라서 오픈소스 프로젝트의 미래는 크게 바뀌겠지요.

만일 정말로 AI 프로그래밍을 누구나 무제한적으로 사용할 수 있다면, 대규모가 아닌 대부분의 오픈소스 프로젝트에는 더이상 협력이 필요하지 않을 것입니다. 진정으로 '어떻게'보다 '무엇을'이 더 중요한 시대가 온다면, 프로젝트의 목표를 확고하게 가진 사람이 극한의 완성도까지 프로젝트를 밀어붙이는 편이 훨씬 좋은 결과를 만들겠지요.

그런 시대가 올지 오지 않을지 모르겠습니다. 하지만 그 전까지는, AI 프로그래밍이 누구에게나 주어지는 기회이지만 프로젝트를 단숨에 완성할만큼 주어지지는 않는 시대가 유지되는 동안에는, 다음 세대의 오픈소스 기여의 방법은 AI 프로그래밍 사용량을 기여하는 것이 하나의 큰 축이 될 것입니다.

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

@_elena @fedify Thanks! 😆

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to dansup's post

@dansup @fedify Thanks!!

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to OS-SCI's post

@os_sci @fedify Oh, that's a good idea! How could we collaborate together? Where should we start?

고남현's avatar
고남현

@gnh1201@hackers.pub

불경한게 많은 세상

세상에는 참 불경(?)한 것들이 많습니다.

개발에 쓰이는 도구가 불경하다며 어떠한 도구도 깔지말고 메모장(Notepad)만으로 코딩을 하라더니, 이제는 데이터베이스가 불경하다며 PC에 이미 설치된 기본 기능만으로 데이터베이스 처럼 사용할 수 있는 방법을 강구하라고 합니다.

아... 먹고 살기 쉽지 않습니다.

하지만 메모장 "Only" 코딩도 성공시킨 제가 데이터베이스라고 성공시키지 못하겠습니까? 해봅시다.

지금까지의 이야기

외부 개발 도구가 매우 제한적으로 공급되는 환경(사실상 쓸 수 있는 개발 도구가 "메모장"밖에 없는 환경)에서도 고급 기능을 구현할 수 있는 방안에 대한 요구는, 윈도우즈 운영체제의 내부 ECMAScript를 활용하는 WelsonJS 프레임워크의 공개를 통해 해결할 수 있었습니다.

WelsonJS 프레임워크는 2025년 6월 26일 기준, 깃허브(GitHub)에서 350개의 긍정적인 평가(Stars)를 받는 성과도 이루었습니다.

이후로, 지금까지의 경험을 데이터베이스 영역으로 확대해보자는 의견이 나오기 시작하였습니다. 상용 데이터베이스의 존재는 그 자체만으로도 매우 거대하기 때문에 외부 개발 도구의 공급이 제한되는 환경에서는 데이터베이스도 업무에 걸림돌이 되기 때문입니다.

결국 운영체제에서 기본적으로 지원되는 데이터베이스로 사용 가능한 시스템이 있는지 사전 조사를 시작합니다.

사전 조사가 끝나다

결국 윈도우즈 운영체제에는 Windows 2000부터 현재 버전(Windows 11)에도 탑재된 오던 ESENT (ESE) 데이터베이스라는 것이 존재한다는걸 확인하게 됩니다.

제 그동안의 경험에 의한 관심법과, LLM(거대 언어 모델)과의 협공이라면 빠른 시간 내에 구현이 가능할거라 생각하지만 상대적으로 정보가 부족하여 생각보다는 오래 걸렸습니다.

데이터베이스 기능 추상화 (칼럼, 스키마, CRUD) 구현

ESENT 데이터베이스를 사용하기 위한 API 구현은 존재하지만, Column(칼럼), Schema(스키마, 혹은 테이블), CRUD(생성, 읽기, 수정, 삭제) 등 실제 어플리케이션을 구현하는 용도로 사용하기에 적합하도록 데이터베이스의 개념을 추상화해둔 구현은 존재하지 않았습니다.

그런고로, 직접 작성을 해보았습니다! 먼저 칼럼과 스키마라는 개념을 구현해봅시다.

// Column.cs (WelsonJS.Esent)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Namhyeon Go <gnh1201@catswords.re.kr>, Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
// 
using System;
using System.Text;
using Microsoft.Isam.Esent.Interop;

namespace WelsonJS.Esent
{
    public class Column
    {
        public string Name { get; set; }
        public JET_coltyp Type { get; set; }
        public int MaxSize { get; set; }
        public JET_CP CodePage { get; set; }
        public bool IsPrimaryKey { get; set; } = false;

        public override string ToString()
        {
            return Name;
        }

        public static explicit operator string(Column c)
        {
            return c.ToString();
        }

        public Column(string name, JET_coltyp type, int maxSize = 0, JET_CP codePage = JET_CP.None)
        {
            Name = name;
            Type = type;
            MaxSize = maxSize;
            CodePage = codePage == JET_CP.None ?
                JET_CP.Unicode : codePage;
        }

        public Column(string name, Type dotNetType, int maxSize = 0, Encoding encoding = null)
        {
            Name = name;
            Type = GetJetColtypFromType(dotNetType);
            MaxSize = maxSize;
            CodePage = GetJetCpFromEncoding(encoding ?? Encoding.Unicode);
        }

        private static JET_coltyp GetJetColtypFromType(Type type)
        {
            if (type == typeof(string)) return JET_coltyp.Text;
            if (type == typeof(int)) return JET_coltyp.Long;
            if (type == typeof(long)) return JET_coltyp.Currency;
            if (type == typeof(bool)) return JET_coltyp.Bit;
            if (type == typeof(float)) return JET_coltyp.IEEESingle;
            if (type == typeof(double)) return JET_coltyp.IEEEDouble;
            if (type == typeof(DateTime)) return JET_coltyp.DateTime;
            if (type == typeof(byte[])) return JET_coltyp.LongBinary;

            throw new NotSupportedException($"Unsupported .NET type: {type.FullName}");
        }

        private static JET_CP GetJetCpFromEncoding(Encoding encoding)
        {
            if (encoding == Encoding.Unicode) return JET_CP.Unicode;
            if (encoding == Encoding.ASCII) return JET_CP.ASCII;
            if (encoding.CodePage == 1252) return (JET_CP)1252; // Windows-1252 / Latin1
            if (encoding.CodePage == 51949) return (JET_CP)51949; // EUC-KR
            if (encoding.CodePage == 949) return (JET_CP)949; // UHC (Windows Korean)
            if (encoding.CodePage == 932) return (JET_CP)932; // Shift-JIS (Japanese)
            if (encoding.CodePage == 936) return (JET_CP)936; // GB2312 (Simplified Chinese)
            if (encoding.CodePage == 65001) return (JET_CP)65001; // UTF-8
            if (encoding.CodePage == 28591) return (JET_CP)28591; // ISO-8859-1

            throw new NotSupportedException($"Unsupported encoding: {encoding.WebName} (code page {encoding.CodePage})");
        }
    }
}

// Schema.cs (WelsonJS.Esent)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Namhyeon Go <gnh1201@catswords.re.kr>, Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
// 
using System;
using System.Collections.Generic;

namespace WelsonJS.Esent
{
    public class Schema
    {
        public string TableName { get; set; }
        public List<Column> Columns { get; set; }
        public Column PrimaryKey
        {
            get
            {
                return Columns.Find(c => c.IsPrimaryKey) ?? null;
            }
        }

        public Schema(string tableName, List<Column> columns)
        {
            TableName = tableName;
            Columns = columns ?? new List<Column>();
        }

        public void SetPrimaryKey(string columnName)
        {
            Column column = Columns.Find(c => c.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase));
            if (column != null)
            {
                column.IsPrimaryKey = true;
            }
            else
            {
                throw new ArgumentException($"Column '{columnName}' does not exist in schema '{TableName}'.");
            }
        }
    }
}

그리고, 대망의 CRUD를 구현해줍니다. (예시의 내용 중 Logger 인터페이스의 활용과 관련된 내용은 생략하였습니다.)

// DataStore.cs (WelsonJS.Esent)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Namhyeon Go <gnh1201@catswords.re.kr>, Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
// 
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.Isam.Esent.Interop;

namespace WelsonJS.Esent
{
    public class EsentDatabase : IDisposable
    {
        private const string _primaryKeyindexName = "primary";
        private const string _indexNamePrefix = "idx_";
        private const string _databaseName = "metadata.edb";

        private readonly ICompatibleLogger _logger;
        private static readonly object _lock = new object();
        private static bool _initialized = false;
        private static Instance _instance;
        private static string _workingDirectory;
        private static string _filePath;

        private readonly Session _session;
        private readonly JET_DBID _dbid;
        private readonly Schema _schema;
        private readonly Column _primaryKey;
        private readonly Dictionary<string, JET_COLUMNID> _columnIds;

        public EsentDatabase(Schema schema, string workingDirectory, ICompatibleLogger logger = null)
        {
            _logger = logger ?? new TraceLogger();

            if (schema == null)
                throw new ArgumentNullException(nameof(schema));

            _primaryKey = schema.PrimaryKey;

            if (_primaryKey == null)
                throw new ArgumentNullException();

            if (!schema.Columns.Exists(c => c == _primaryKey))
                throw new ArgumentException($"Primary key '{_primaryKey.Name}' is not in schema.");

            _workingDirectory = workingDirectory;
            _schema = schema;
            _columnIds = new Dictionary<string, JET_COLUMNID>(StringComparer.OrdinalIgnoreCase);

            InitializeInstance();

            _session = new Session(_instance);

            if (!File.Exists(_filePath))
            {
                Api.JetCreateDatabase(_session, _filePath, null, out _dbid, CreateDatabaseGrbit.None);
                CreateTable(_schema);
            }
            else
            {
                Api.JetAttachDatabase(_session, _filePath, AttachDatabaseGrbit.None);
                Api.JetOpenDatabase(_session, _filePath, null, out _dbid, OpenDatabaseGrbit.None);
            }

            CacheColumns();
        }

        private static void InitializeInstance()
        {
            if (_initialized) return;

            lock (_lock)
            {
                if (_initialized) return;

                // set the file path
                _filePath = Path.Combine(_workingDirectory, _databaseName);

                // config the instance
                _instance = new Instance(typeof(EsentDatabase).Namespace);
                _instance.Parameters.SystemDirectory = _workingDirectory;
                _instance.Parameters.LogFileDirectory = _workingDirectory;
                _instance.Parameters.TempDirectory = _workingDirectory;

                // initialize the instance
                _instance.Init();
                _initialized = true;
            }
        }

        private void CreateTable(Schema schema)
        {
            Api.JetBeginTransaction(_session);
            JET_TABLEID tableid;
            Api.JetCreateTable(_session, _dbid, schema.TableName, 0, 100, out tableid);

            foreach (var col in schema.Columns)
            {
                var coldef = new JET_COLUMNDEF
                {
                    coltyp = col.Type,
                    cbMax = col.MaxSize,
                    cp = col.CodePage
                };
                Api.JetAddColumn(_session, tableid, col.Name, coldef, null, 0, out _);
            }

            CreateIndex(tableid, new[] { _primaryKey }, CreateIndexGrbit.IndexPrimary | CreateIndexGrbit.IndexUnique);

            Api.JetCloseTable(_session, tableid);
            Api.JetCommitTransaction(_session, CommitTransactionGrbit.None);
        }

        public void CreateIndex(JET_TABLEID tableid, IEnumerable<Column> columns, CreateIndexGrbit grbit)
        {
            if (columns == null)
                throw new ArgumentNullException(nameof(columns));

            var columnList = columns.ToList();
            if (columnList.Count == 0)
                throw new ArgumentException("At least one column is required to create an index.", nameof(columns));

            if (tableid == JET_TABLEID.Nil)
                throw new ArgumentException("Invalid table ID.", nameof(tableid));

            bool isPrimaryKeyIndex = (columnList.Count == 1 && columnList[0].IsPrimaryKey);

            if (isPrimaryKeyIndex && (grbit & CreateIndexGrbit.IndexPrimary) == 0)
                throw new ArgumentException("Primary key index must have the CreateIndexGrbit.IndexPrimary flag set.", nameof(grbit));

            string indexName = isPrimaryKeyIndex
                ? _primaryKeyindexName
                : _indexNamePrefix + string.Join("_", columnList.Select(c => c.Name));

            string key = string.Concat(columnList.Select(c => "+" + c.Name));
            string keyDescription = key + "\0\0"; // double null-terminated
            int keyDescriptionLength = keyDescription.Length;

            Api.JetCreateIndex(
                _session,
                tableid,
                indexName,
                grbit,
                keyDescription,
                keyDescriptionLength,
                100
            );
        }

        private void CacheColumns()
        {
            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.ReadOnly))
            {
                foreach (var col in _schema.Columns)
                {
                    try
                    {
                        JET_COLUMNID colid = Api.GetTableColumnid(_session, table, col.Name);
                        _columnIds[col.Name] = colid;
                    }
                    catch (EsentColumnNotFoundException)
                    {
                        _logger.Warn($"Column '{col.Name}' not found.");
                    }
                }
            }
        }

        public bool Insert(Dictionary<string, object> values, out object key)
        {
            return TrySaveRecord(values, JET_prep.Insert, expectSeek: false, out key);
        }

        public bool Update(Dictionary<string, object> values)
        {
            return TrySaveRecord(values, JET_prep.Replace, expectSeek: true, out _);
        }

        private bool TrySaveRecord(
            Dictionary<string, object> values,
            JET_prep prepType,
            bool expectSeek,
            out object primaryKeyValue)
        {
            primaryKeyValue = null;

            if (!TryGetPrimaryKeyValue(values, out var keyValue))
                return false;

            var keyType = _primaryKey.Type;

            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.Updatable))
            {
                try
                {
                    Api.JetBeginTransaction(_session);

                    Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);
                    MakeKeyByType(keyValue, keyType, _session, table);
                    bool found = Api.TrySeek(_session, table, SeekGrbit.SeekEQ);

                    if (expectSeek != found)
                    {
                        _logger.Warn($"[ESENT] Operation skipped. Seek result = {found}, expected = {expectSeek}");
                        Api.JetRollback(_session, RollbackTransactionGrbit.None);
                        return false;
                    }

                    Api.JetPrepareUpdate(_session, table, prepType);
                    SetAllColumns(values, table);

                    Api.JetUpdate(_session, table);
                    Api.JetCommitTransaction(_session, CommitTransactionGrbit.None);

                    if (prepType == JET_prep.Insert)
                        primaryKeyValue = keyValue;

                    return true;
                }
                catch (Exception ex)
                {
                    Api.JetRollback(_session, RollbackTransactionGrbit.None);
                    throw new InvalidOperationException($"[ESENT] Operation failed: {ex.Message}");
                }
            }
        }

        public Dictionary<string, object> FindById(object keyValue)
        {
            var result = new Dictionary<string, object>();
            var keyType = _primaryKey.Type;

            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.ReadOnly))
            {
                Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);
                MakeKeyByType(keyValue, keyType, _session, table);
                if (!Api.TrySeek(_session, table, SeekGrbit.SeekEQ))
                    return null;

                foreach (var col in _schema.Columns)
                {
                    if (!_columnIds.TryGetValue(col.Name, out var colid))
                        continue;

                    var value = RetrieveColumnByType(_session, table, colid, col.Type);
                    result[col.Name] = value;
                }
            }

            return result;
        }

        public List<Dictionary<string, object>> FindAll()
        {
            var results = new List<Dictionary<string, object>>();

            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.ReadOnly))
            {
                Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);

                if (!Api.TryMoveFirst(_session, table))
                    return results;

                do
                {
                    var row = new Dictionary<string, object>();
                    foreach (var col in _schema.Columns)
                    {
                        if (!_columnIds.TryGetValue(col.Name, out var colid))
                            continue;

                        var value = RetrieveColumnByType(_session, table, colid, col.Type);
                        row[col.Name] = value;
                    }
                    results.Add(row);
                }
                while (Api.TryMoveNext(_session, table));
            }

            return results;
        }

        public bool DeleteById(object keyValue)
        {
            var keyType = _primaryKey.Type;

            using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.Updatable))
            {
                Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);
                MakeKeyByType(keyValue, keyType, _session, table);
                if (!Api.TrySeek(_session, table, SeekGrbit.SeekEQ))
                    return false;

                Api.JetDelete(_session, table);
                return true;
            }
        }

        private object RetrieveColumnByType(Session session, Table table, JET_COLUMNID columnId, JET_coltyp type)
        {
            switch (type)
            {
                case JET_coltyp.Text:
                    return Api.RetrieveColumnAsString(session, table, columnId, Encoding.Unicode);
                case JET_coltyp.Long:
                    return Api.RetrieveColumnAsInt32(session, table, columnId);
                case JET_coltyp.IEEEDouble:
                    return Api.RetrieveColumnAsDouble(session, table, columnId);
                case JET_coltyp.DateTime:
                    return Api.RetrieveColumnAsDateTime(session, table, columnId);
                case JET_coltyp.Binary:
                case JET_coltyp.LongBinary:
                    return Api.RetrieveColumn(session, table, columnId);
                default:
                    _logger.Warn($"[ESENT] Unsupported RetrieveColumn type: {type}");
                    return null;
            }
        }

        private bool TryGetPrimaryKeyValue(Dictionary<string, object> values, out object keyValue)
        {
            keyValue = null;

            if (!values.TryGetValue(_primaryKey.Name, out keyValue))
            {
                _logger.Warn($"[ESENT] Missing primary key '{_primaryKey.Name}'.");
                return false;
            }

            if (keyValue == null)
            {
                _logger.Warn("[ESENT] Primary key value cannot be null.");
                return false;
            }

            return true;
        }

        private JET_coltyp GetColumnType(string columnName)
        {
            var column = _schema.Columns.FirstOrDefault(c => c.Name == columnName);
            if (column == null)
                throw new ArgumentException($"Column '{columnName}' not found in schema.");

            return column.Type;
        }

        private void SetAllColumns(Dictionary<string, object> values, Table table)
        {
            foreach (var kv in values)
            {
                if (!_columnIds.TryGetValue(kv.Key, out var colid))
                {
                    _logger.Warn($"[ESENT] Column '{kv.Key}' not found in cache.");
                    continue;
                }

                var colType = GetColumnType(kv.Key);
                SetColumnByType(_session, table, colid, kv.Value, colType);
            }
        }

        private void SetColumnByType(Session session, Table table, JET_COLUMNID columnId, object value, JET_coltyp type)
        {
            if (value == null)
                return;

            switch (type)
            {
                case JET_coltyp.Text:
                    Api.SetColumn(session, table, columnId, value.ToString(), Encoding.Unicode);
                    break;
                case JET_coltyp.Long:
                    Api.SetColumn(session, table, columnId, Convert.ToInt32(value));
                    break;
                case JET_coltyp.IEEEDouble:
                    Api.SetColumn(session, table, columnId, Convert.ToDouble(value));
                    break;
                case JET_coltyp.DateTime:
                    Api.SetColumn(session, table, columnId, Convert.ToDateTime(value));
                    break;
                case JET_coltyp.Binary:
                case JET_coltyp.LongBinary:
                    Api.SetColumn(session, table, columnId, (byte[])value);
                    break;
                default:
                    _logger.Warn($"[ESENT] Unsupported SetColumn type: {type}");
                    break;
            }
        }

        private void MakeKeyByType(object value, JET_coltyp type, Session session, Table table)
        {
            switch (type)
            {
                case JET_coltyp.Text:
                    Api.MakeKey(session, table, value.ToString(), Encoding.Unicode, MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.Long:
                    Api.MakeKey(session, table, Convert.ToInt32(value), MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.IEEEDouble:
                    Api.MakeKey(session, table, Convert.ToDouble(value), MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.DateTime:
                    Api.MakeKey(session, table, Convert.ToDateTime(value), MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.Binary:
                case JET_coltyp.LongBinary:
                    Api.MakeKey(session, table, (byte[])value, MakeKeyGrbit.NewKey);
                    break;
                default:
                    _logger.Warn($"[ESENT] Unsupported MakeKey type: {type}");
                    break;
            }
        }

        public void Dispose()
        {
            _session?.Dispose();
        }
    }
}

이렇게하면 ESENT (ESE) 데이터베이스에서 어플리케이션 개발 용도에 적합한 칼럼, 스키마 및 CRUD를 위한 메소드를 구현할 수 있습니다.

활용

이렇게 만들어진 구현은 다음과 같이 사용할 수 있습니다.

using WelsonJS.Esent;

// connect the database to manage instances
Schema schema = new Schema("Instances", new List<Column>
{
    new Column("InstanceId", typeof(string), 255),
    new Column("FirstDeployTime", typeof(DateTime), 1)
});
schema.SetPrimaryKey("InstanceId");
_db = new EsentDatabase(schema, Path.GetTempPath());

// Insert row
try
{
    _db.Insert(new Dictionary<string, object>
    {
        ["InstanceId"] = instanceId,
        ["FirstDeployTime"] = now
    }, out _);
}
catch (Exception ex)
{
    // Handle exception
}

// find all
var instances = _db.FindAll();
foreach (var instance in instances)
{
    try
    {
        string instanceId = instance["InstanceId"].ToString();
        string firstDeployTime = instance.ContainsKey("FirstDeployTime")
            ? ((DateTime)instance["FirstDeployTime"]).ToString("yyyy-MM-dd HH:mm:ss")
            : "Unknown";

        Console.WriteLine($"{firstDeployTime}, {instanceId}");
    }
    catch (Exception ex)
    {
        // Handle exception
    }
}

우리에게 다소 익숙한 어플리케이션 개발에 적합한 메소드를 지원하고 있음을 확인할 수 있습니다.

Park Hyunwoo's avatar
Park Hyunwoo

@lqez@mastodon.cloud

오늘 만난 분에게 유튜브 출연 제의를 하다 알게 되었는데, 생각보다 부모님도 프로그래머인 경우가 – 그리고 아직도 2대에 걸쳐 현업인 경우도 이제는 꽤 많을 것 같다. 그래서 혹시 동반 출연 가능하실지 여쭤봤는데 과연… 관심있는 분들의 DM 기다립니다 ㅋㅋ

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Excited to share that I've joined (Open Source Software Contribution Academy) as a mentor for the @fedify project!

OSSCA is a national program run by South Korea's NIPA (National IT Industry Promotion Agency) through their Open Source Software Support Center, aimed at fostering the next generation of open source contributors.

We're currently in the process of selecting around 20 mentees who will start contributing to once the selection is complete. I've been busy preparing good first issues to help them get started on their open source journey.

Looking forward to working with these new contributors and seeing what amazing things we can build together!

Deno's avatar
Deno

@deno_land@fosstodon.org

Coming soon

월퍄's avatar
월퍄

@wolffia@bakedbean.xyz

이유는 모르겠는데 구글키보드
대만 필기인식에 구결자가 있다

Fedify: ActivityPub server framework's avatar
Fedify: ActivityPub server framework

@fedify@hollo.social

We're pleased to share that Encyclia has joined our success stories.

@encyclia bridges academic research to the by making researcher profiles and publications discoverable through —built with for seamless interoperability across Mastodon and other fediverse platforms.

This demonstrates Fedify's versatility beyond traditional social networking, helping specialized domains connect to the federated web.

We're also grateful for 's sponsorship support, which helps make Fedify's development possible.

Learn more about Encyclia at https://encyclia.pub/. 📚

Fedify: ActivityPub server framework's avatar
Fedify: ActivityPub server framework

@fedify@hollo.social

We are pleased to announce the release of 1.7.0. This release was expedited at the request of the Ghost team, who are actively using Fedify for their implementation. As a result, several features originally planned for this version have been moved to Fedify 1.8.0 to ensure timely delivery of the most critical improvements.

This release focuses on enhancing message queue functionality and improving compatibility with ActivityPub servers through refined HTTP signature handling.

Native retry mechanism support

This release introduces support for native retry mechanisms in message queue backends. The new MessageQueue.nativeRetrial property allows queue implementations to indicate whether they provide built-in retry functionality, enabling Fedify to optimize its retry behavior accordingly.

When nativeRetrial is set to true, Fedify will delegate retry handling to the queue backend rather than implementing its own retry logic. This approach reduces overhead and leverages the proven retry mechanisms of established queue systems.

Current implementations with native retry support include:

  • DenoKvMessageQueue — utilizes Deno KV's automatic retry with exponential backoff
  • WorkersMessageQueue — leverages Cloudflare Queues' automatic retry and dead-letter queue features
  • AmqpMessageQueue — can now be configured to use AMQP broker's native retry mechanisms

The InProcessMessageQueue continues to use Fedify's internal retry mechanism, while ParallelMessageQueue inherits the retry behavior from its wrapped queue.

AMQP message queue improvements

Alongside Fedify 1.7.0, we have also released @fedify/amqp 0.3.0. This release adds the nativeRetrial option to AmqpMessageQueueOptions, enabling you to leverage your AMQP broker's built-in retry mechanisms. When enabled, this option allows the AMQP broker to handle message retries according to its configured policies, rather than relying on Fedify's internal retry logic.

Configurable double-knocking

The new FederationOptions.firstKnock option provides control over the HTTP Signatures specification used for the initial signature attempt when communicating with previously unknown servers.

Previously, the first knock for newly encountered servers always used RFC 9421 (HTTP Message Signatures), falling back to draft-cavage-http-signatures-12 if needed. With this release, you can now configure which specification to use for the first knock when communicating with unknown servers, with RFC 9421 remaining the default.

Summary

This release maintains Fedify's commitment to reliability and compatibility while laying the groundwork for more efficient message processing. The native retry mechanism support will particularly benefit applications using queue backends with sophisticated retry capabilities, while the double-knocking mechanism addresses real-world compatibility challenges in the ActivityPub ecosystem.

For detailed technical information about these changes, please refer to the changelog in the repository.

Emelia 👸🏻's avatar
Emelia 👸🏻

@thisismissem@hachyderm.io

Oh! Yay! This shipped!!

Coming in Mastodon 4.4, mods will be able to leave notes directly on instances just like they can on reports and accounts.

That's my big 4.4 feature. This is way better than using the private_comment field in domain blocks.
vmst.io/@mergebot/114743061796

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

韓國女性民友會(한국여성민우회) 티셔츠 申請(신청) 完了(완료)!

한국여성민우회🎗's avatar
한국여성민우회🎗

@womenlink.or.kr@bsky.brid.gy

[앵콜제작❤️] 민우회 티셔츠를 만날 수 있는 정말(眞) 마지막 기회! 가장 인기 있었던 티셔츠, 가장 요청이 많았던 티셔츠 (新)블랙에디션(NEW) 2종을 추가제작합니다. 사전신청: 6/25(수)~7/7(월)까지 신청폼: forms.gle/vqpe6XJrgfJg...

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub

Hackers' Pub이 커뮤니티 자격으로 올해 파이콘 한국에 후원하게 되어, 8월 16일(土)–17일(日) 후원사 부스를 운영하게 되었는데요. 부스 운영을 도와주실 분을 한 분에서 두 분 정도 찾습니다! 이틀 중 하루만 도와주셔도 좋습니다. (당연하지만 저는 이틀 모두 나갑니다.) 도와주신 분께는 약소하지만 제가 점심과 저녁을 대접하겠습니다.

geeknews_bot's avatar
geeknews_bot

@geeknews_bot@sns.lemondouble.com

JavaScript 라이브러리를 위한 새로운 로깅 접근법: LogTape
------------------------------
### 라이브러리 vs 애플리케이션: 근본적으로 다른 로깅 요구사항
- *애플리케이션 로깅* : 개발자가 직접 제어하는 환경에서 명시적 설정과 관리
- *라이브러리 로깅* : 타인의 프로젝트에 포함되어 사용자 환경과 선택권 존중 필요
- *기존 방식의 한계* : 애플리케이션 중심 로거(winston, Pino)를 라이브러…
------------------------------
https://news.hada.io/topic?id=21610&utm_source=googlechat&utm_medium=bot&utm_campaign=1834

BotKit by Fedify :botkit:'s avatar
BotKit by Fedify :botkit:

@botkit@hollo.social

We're pleased to announce that .js support has been merged and will be available in 0.3.0.

Now you can build your bots with both and Node.js, giving you more flexibility in choosing your preferred runtime environment.

Stay tuned for BotKit 0.3.0!

Deno's avatar
Deno

@deno_land@fosstodon.org

Deno 2.3.7 was released with a bunch of bug fixes and now is using aws-lc

github.com/denoland/deno/relea

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub


One of the enduring challenges in software programming is this: “How do we pass the invisible?” Loggers, HTTP request contexts, current locales, I/O handles—these pieces of information are needed throughout our programs, yet threading them explicitly through every function parameter would be unbearably verbose.

Throughout history, various approaches have emerged to tackle this problem. Dynamic scoping, aspect-oriented programming, context variables, and the latest effect systems… Some represent evolutionary steps in a continuous progression, while others arose independently. Yet we can view all these concepts through a unified lens.

Dynamic scoping

Dynamic scoping, which originated in 1960s Lisp, offered the purest form of solution. “A variable's value is determined not by where it's defined, but by where it's called.” Simple and powerful, yet it fell out of favor in mainstream programming languages after Common Lisp and Perl due to its unpredictability. Though we can still trace its lineage in JavaScript's this binding.

;; Common Lisp example - logger bound dynamically
(defvar *logger* nil)

(defun log-message (message)
  (when *logger*
    (funcall *logger* message)))

(defun process-user-data (data)
  (log-message (format nil "Processing user: ~a" data))
  ;; actual processing logic…
  )

(defun main ()
  (let ((*logger* (lambda (msg) (format t "[INFO] ~a~%" msg))))
    (process-user-data "john@example.com"))) ; logger passed implicitly

Aspect-oriented programming

AOP structured the core idea of “modularizing cross-cutting concerns.” The philosophy: “Inject context, but with rules.” By separating cross-cutting concerns like logging and transactions into aspects, it maintained dynamic scoping's flexibility while pursuing more predictable behavior. However, debugging difficulties and performance overhead limited its spread beyond Java and .NET ecosystems.

// Spring AOP example - logging separated as cross-cutting concern
@Aspect
public class LoggingAspect {
    private Logger logger = LoggerFactory.getLogger(LoggingAspect.class);
    
    @Around("@annotation(Loggable)")
    public Object logMethodCall(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().getName();
        logger.info("Entering method: " + methodName);
        Object result = joinPoint.proceed();
        logger.info("Exiting method: " + methodName);
        return result;
    }
}

@Service
public class UserService {
    @Loggable  // logger implicitly injected through aspect
    public User processUser(String userData) {
        // actual processing logic…
        return new User(userData);
    }
}

Context variables

Context variables represent dynamic scoping redesigned for modern requirements—asynchronous and parallel programming. Python's contextvars and Java's ThreadLocal exemplify this approach. Yet they still suffer from runtime dependency and the fact that API context requirements are only discoverable through documentation.

Another manifestation of context variables appears in React's contexts and similar concepts in other UI frameworks. While their usage varies, they all solve the same problem: prop drilling. Implicit propagation through component trees mirrors propagation through function call stacks.

# Python contextvars example - custom logger propagated through context
from contextvars import ContextVar

# Define custom logger function as context variable
logger_func = ContextVar('logger_func')

def log_info(message):
    log_fn = logger_func.get()
    if log_fn:
        log_fn(f"[INFO] {message}")

def process_user_data(data):
    log_info(f"Processing user: {data}")
    validate_user_data(data)

def validate_user_data(data):
    log_info(f"Validating user: {data}")  # logger implicitly propagated

def main():
    # Set specific logger function in context
    def my_logger(msg):
        print(f"CustomLogger: {msg}")
    
    logger_func.set(my_logger)
    process_user_data("john@example.com")

Monads

Monads approach this from a different starting point. Rather than implicit context passing, monads attempt to encode effects in the type system—addressing a more fundamental problem. The Reader monad specifically corresponds to context variables. However, when combining multiple effects through monad transformers, complexity exploded. Developers had to wrestle with unwieldy types like ReaderT Config (StateT AppState (ExceptT Error IO)). Layer ordering mattered, each layer required explicit lifting, and usability suffered. Consequently, monadic ideas remained largely confined to serious functional programming languages like Haskell, Scala, and F#.

-- Haskell Logger monad example - custom Logger monad definition
newtype Logger a = Logger (IO a)

instance Functor Logger where
    fmap f (Logger io) = Logger (fmap f io)

instance Applicative Logger where
    pure = Logger . pure
    Logger f <*> Logger x = Logger (f <*> x)

instance Monad Logger where
    Logger io >>= f = Logger $ do
        a <- io
        let Logger io' = f a
        io'

-- Logging functions
logInfo :: String -> Logger ()
logInfo msg = Logger $ putStrLn $ "[INFO] " ++ msg

processUserData :: String -> Logger ()
processUserData userData = do
    logInfo $ "Processing user: " ++ userData
    validateUserData userData

validateUserData :: String -> Logger ()
validateUserData userData = do
    logInfo $ "Validating user: " ++ userData  -- logger passed through monad

runLogger :: Logger a -> IO a
runLogger (Logger io) = io

main :: IO ()
main = runLogger $ processUserData "john@example.com"

Effect systems

Effect systems emerged to solve the compositional complexity of monads. Implemented in languages like Koka and Eff, they operate through algebraic effects and handlers. Multiple effect layers compose without ordering constraints. Multiple overlapping layers require no explicit lifting. Effect handlers aren't fixed—they can be dynamically replaced, offering significant flexibility.

However, compiler optimizations remain immature, interoperability with existing ecosystems poses challenges, and the complexity of effect inference and its impact on type systems present ongoing research questions. Effect systems represent the newest approach discussed here, and their limitations will be explored as they gain wider adoption.

// Koka effect system example - logging effects flexibly propagated
effect logger
  fun log-info(message: string): ()
  fun log-error(message: string): ()

fun process-user-data(user-data: string): logger ()
  log-info("Processing user: " ++ user-data)
  validate-user-data(user-data)

fun validate-user-data(user-data: string): logger ()
  log-info("Validating user: " ++ user-data)  // logger effect implicitly propagated
  if user-data == "" then
    log-error("Invalid user data: empty string")

fun main()
  // Different logger implementations can be chosen dynamically
  with handler
    fun log-info(msg) println("[INFO] " ++ msg)
    fun log-error(msg) println("[ERROR] " ++ msg)
  process-user-data("john@example.com")

The art of passing the invisible—this is the essence shared by all the concepts discussed here, and it will continue to evolve in new forms as an eternal theme in software programming.

bgl gwyng's avatar
bgl gwyng

@bgl@hackers.pub

주말에 튜사 모각코하실분 있나요~

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to Brandon Zhang 🇨🇳's post

@heybran Oh, yeah, scallions are what my brother (who cooked that 麻婆豆腐) missed! That would be even more delicious…

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

동생이 만들어 준 麻婆豆腐(마파두부)!

麻婆豆腐
ALT text details麻婆豆腐
:rss: Hacker News

@ycombinator@rss-mstdn.studiofreesia.com

If you're building a JavaScript library and need logging, you would love LogTape
hackers.pub/@hongminhee/2025/l

Curated Hacker News's avatar
Curated Hacker News

@CuratedHackerNews@mastodon.social

If you're building a JavaScript library and need logging, you would love LogTape

hackers.pub/@hongminhee/2025/l

Kagami is they/them 🏳️‍⚧️'s avatar
Kagami is they/them 🏳️‍⚧️

@krosylight@fosstodon.org

Requiring CW for all politics only works for people who have life that is not marked as "political"

Gamers are still convinced that there are only:

Two races: white and "political"
Two genders: Male and "political"
Two hair styles for women: long and "political"
Two sexualities: straight and "political"
Two body types: normative and "political"
ALT text detailsGamers are still convinced that there are only: Two races: white and "political" Two genders: Male and "political" Two hair styles for women: long and "political" Two sexualities: straight and "political" Two body types: normative and "political"
Emelia 👸🏻's avatar
Emelia 👸🏻

@thisismissem@hachyderm.io

Just learned of a self-hostable ticketing software called Pretix: pretix.eu/

It looks extremely full-featured.

Deno's avatar
Deno

@deno_land@fosstodon.org

the story behind the 🥚logo

← Newer
Older →